Data Fetching with TanStack
Data Fetching with TanStack

TanStack Router: typed routes, search state and loaders

Full · ~40 min
After this unit you can
  • Build a typed route tree — file-based (createFileRoute plus a generated tree) or code-based (createRoute, getParentRoute, addChildren) — and register the router so Link and navigate are type-checked
  • Write a validateSearch that turns untrusted URL search params into typed state with fallbacks, and update search with an updater that keeps the other params
  • Load a route's data before render: loaderDeps for the search values the loader uses, and a loader that fills the query cache (queryClient.query({ …, staleTime: 'static' }), formerly ensureQueryData) for useSuspenseQuery to read
  • Predict what the user sees on a navigation: when the loader re-runs, when the pending component appears, and what a missing loaderDeps does

Read

Simple
1 / 2

A route tree TypeScript can check

Flag

TanStack Router v1 treats your routes as typed data. Every path, path param and search param is known to TypeScript, so a typo in a link is a compile error instead of a 404 in production.

There are two ways to declare the tree. File-based routing is the one the docs recommend: a file per route under src/routes/, each exporting a route made with createFileRoute, and a bundler plugin (or the Router CLI) that generates routeTree.gen.ts — the tree plus its types — and keeps the path strings in sync when you move files.

// src/routes/posts.tsx
export const Route = createFileRoute("/posts")({
  component: PostsPage,
});

Code-based routing builds the same tree by hand. It's what you'll see in small examples and tests, and it shows what the generator does for you:

const rootRoute = createRootRoute();
const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: "posts" });
const postRoute = createRoute({ getParentRoute: () => rootRoute, path: "posts/$postId" });

const routeTree = rootRoute.addChildren([postsRoute, postRoute]);
const router = createRouter({ routeTree });

getParentRoute links each route to its parent. At runtime the router uses it to build the route's full path (a child's path is relative to its parent's); in the types, it's how a child inherits its parents' params, search and context. The docs stress the types, but it isn't optional at runtime either: without it, the router throws an invariant error when it builds the tree. addChildren then assembles the tree the router matches against.

One more line makes the global exports — Link, useNavigate, redirect — check against your tree. You register the router's type with the library:

declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

After that, with Router 1.170 and TypeScript 5.9, the first two links fail to compile and the third is the fix:

<Link to="/post">…</Link>             // '"/post"' is not assignable … Did you mean '"/posts"'?
<Link to="/posts/$postId">…</Link>    // Property 'params' is missing
<Link to="/posts/$postId" params={{ postId: "5" }}>…</Link> // ✅

Inside a route's own component, use the route's hooks — Route.useParams(), Route.useSearch(), Route.useLoaderData() — which are typed for that route without any hint.

Medium

The loader fills the query cache; the component reads it

Flag

Fetching in a component starts only after it renders, and a child's fetch starts only after its parent's has finished — a waterfall, with a spinner at each level. A route loader starts loading as soon as the router knows where the user is going, before anything renders. With TanStack Query, the loader doesn't own the data; it makes sure the query cache has it.

// the root route declares what every loader receives
const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
  component: () => <Outlet />,
});

const postsQuery = (search: PostsSearch) =>
  queryOptions({ queryKey: ["posts", search], queryFn: () => fetchPosts(search) });

const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "posts",
  validateSearch: parsePostsSearch, // the validator from the search-params example
  loaderDeps: ({ search }) => ({ page: search.page, tag: search.tag }),
  loader: ({ context, deps }) =>
    context.queryClient.query({ ...postsQuery(deps), staleTime: "static" }),
  pendingComponent: () => <p>Loading posts…</p>,
  component: function PostsPage() {
    const search = postsRoute.useSearch();
    const { data } = useSuspenseQuery(postsQuery(search));
    return <PostList posts={data.items} />;
  },
});

const router = createRouter({
  routeTree: rootRoute.addChildren([postsRoute]),
  context: { queryClient },
  defaultPreloadStaleTime: 0,
});

Line by line:

  • createRootRouteWithContext<{ queryClient }>() (note the double call) types the router context; createRouter must then be given a queryClient, and every loader receives it as context.queryClient.
  • loaderDeps lists the search values the loader uses. The loader reads them from deps, and when they change between navigations the router re-runs the loader — "regardless of staleTime", in the docs' words. Return only what the loader uses: returning the whole search reloads on every unrelated param.
  • queryClient.query({ ...options, staleTime: "static" }) returns the cached data if there is any and fetches otherwise. That's the read-or-fetch the docs now use in loaders. It replaced queryClient.ensureQueryData(options), which Query v5.102 deprecated (still works, to be removed in v6) — most existing code and older guides use that name. Run side by side headlessly, both called the queryFn once for two loads; a plain query(options) (default staleTime: 0) called it twice.
  • Returning the promise makes the router wait for it. The navigation completes when the data is in the cache, so the component never sees a loading state for it.
  • useSuspenseQuery(postsQuery(search)) in the component reads the same key — the factory guarantees it — and subscribes to it, so invalidations and refetches still update the page. Its data is never undefined.
  • defaultPreloadStaleTime: 0 — the router caches loader results too (preloads are fresh for 30 seconds by default). The docs recommend 0 with an external cache so that Query, not the router, decides whether to fetch.

What the user sees when they click Next (checked against Router 1.170 with a slow server): the page they're on stays on screen while the loader runs; only if it takes longer than pendingMs (default 1 second) does the pendingComponent appear — and then for at least pendingMinMs (default 500 ms), so it doesn't flash.

Complex

Where the router and the query cache trip each other up

Flag

Checked against TanStack Router 1.170 with TanStack Query 5.104 unless a doc is quoted.

A loader that reads search without loaderDeps

loader: ({ context, location }) => {
  const { page } = location.search as PostsSearch; // ❌ the router doesn't know about this
  return context.queryClient.query({ ...postsQuery({ page }), staleTime: "static" });
},

It works on the first load. But the router keys a loader by pathname plus loaderDeps, so going from ?page=1 to ?page=2 is, as far as it knows, the same loader: it didn't run the loader again in a headless run. The component then asks useSuspenseQuery for page 2, finds nothing cached, and suspends — and the route's pendingComponent appeared at once instead of the old page staying on screen. Same data in the end, plus a loading flash and the waterfall the loader was meant to prevent. Declare loaderDeps and read deps.

loaderDeps: ({ search }) => search

The opposite mistake. Every search param becomes a loader dependency, so toggling a view mode or sort direction that the loader never reads re-runs it. The docs: "only include dependencies you actually use in the loader."

A search param every link must now pass

A plain validateSearch function whose output has a required key — page: number — makes search required on every Link and navigate to that route: <Link to="/posts"> fails to type-check (it did here, even with the function's parameter typed { page?: number }: a plain function's parameter type only becomes the link's input type when it's tagged & SearchSchemaInput, per the route options reference). The Search Params guide's answer is a schema with defaults: Zod v4 or Valibot schemas passed directly (they implement Standard Schema, so the router can tell input from output), or Zod v3 through @tanstack/zod-adapter's zodValidator. Then the link's input type has page optional while the route still reads a number.

Two caches, one owner

The router caches loader results too: navigation staleTime 0, preloads fresh for 30 s, gcTime 5 minutes. With TanStack Query in charge of the data, that's a second layer of freshness rules. The docs' recommendation is defaultPreloadStaleTime: 0, so a preload never short-circuits Query's own decision. And after a mutation you invalidate queries, not routes: the component is subscribed through useSuspenseQuery, so it re-renders when the refetch lands. (router.invalidate() re-runs loaders — useful when loader data lives in the router cache.)

from, and components shared between routes

useSearch({ from: "/posts" }) is typed for that route and throws at runtime if rendered under a different one ("Could not find an active match"). A component used on several routes passes strict: false instead and gets every route's search params merged into one object, each key optional. Inside the route's own file, Route.useSearch() needs no hint.

Quiz

What does declare module '@tanstack/react-router' { interface Register { router: typeof router } } buy you?

Flag

In code-based routing, why does every non-root createRoute need getParentRoute: () => parentRoute?

Flag

?page=2 arrives in validateSearch as the number 2. Why validate at all?

Flag

On /posts?page=1&tag=react, the Next link is <Link from="/posts" search={{ page: page + 1 }}>. Where does it go?

Flag

A /posts loader reads page from location.search and has no loaderDeps. The user clicks from page 1 to page 2. What happens?

Flag

Which loader body matches what ensureQueryData did, in TanStack Query v5.102+?

Flag

With default router options and loaderDeps in place, the user clicks Next and page 2's loader takes 3 seconds. What do they see?

Flag

Fill in the blank

Declare a code-based child route and build the tree.

Flag
const postsRoute = createRoute({
  : () => rootRoute,
  path: "posts",
});

const routeTree = rootRoute.([postsRoute]);

Validate the search: a page that isn't a positive integer falls back to 1.

Flag
validateSearch: (search: Record<string, unknown>): PostsSearch => {
  const page = (search.page);
  return { page: Number.(page) && page > 0 ? page : 1 };
},

Make the loader depend on page only, and fill the query cache before render.

Flag
: ({ search }) => ({ page: search.page }),
loader: ({ context,  }) =>
  context.queryClient.query({ ...postsQuery(deps), staleTime: "" }),

Go to the next page and keep every other search param.

Flag
<Link from="/posts" search={() => ({ ...prev, page: prev.page + 1 })}>Next</Link>

Predict the output

// The router starts at /posts?page=2&desc=true&tag=react&q=007
validateSearch: (search: Record<string, unknown>) => {
  console.log(Object.entries(search).map(([k, v]) => `${k}:${typeof v}`).join(" "));
  return search;
},
Flag
const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "posts",
  validateSearch: parsePostsSearch,
  loader: ({ context, location }) =>
    context.queryClient.query({ ...postsQuery(location.search), staleTime: "static" }),
  pendingComponent: () => <p>Loading posts…</p>,
  component: () => {
    const search = postsRoute.useSearch();
    const { data } = useSuspenseQuery(postsQuery(search));
    return <PostList posts={data.items} />;
  },
});
// On page 1, the user clicks Next. Page 2 takes 2 seconds to load.
Flag
// Current URL: /posts?page=2&tag=react&sort=new
<Link from="/posts" search={{ page: 3 }}>A</Link>
<Link from="/posts" search={(prev) => ({ ...prev, page: 3 })}>B</Link>
Flag

Challenge

Validate the search params and wire them into the loader

Flag

A paged, filterable posts list built on TanStack Router v1 (code-based routes) and TanStack Query v5. The route validates its search params, its loader puts the page into the query cache before the page renders, and the component reads it with useSuspenseQuery. Three things are wrong:

  • A hand-edited URL like /posts?page=abc sends "abc" to the API and the page errors.
  • Every Next flashes Loading posts…: the loader doesn't run again, so the component ends up fetching by itself.
  • On /posts?page=1&tag=react, Next goes to page 2 of all posts — the tag is lost.

Fix postsRoute.tsx. The root route, router.tsx and api.ts are fine as they are.

The tests create the router on an in-memory history at a given URL, with their own QueryClient and a fake server they can pause.

Your task

Edit postsRoute.tsx so the tests pass:

  • /posts?page=2&tag=react renders page 2 of the react posts
  • A page that isn't a positive integer (abc, 0, -3, 1.5) falls back to page 1 instead of erroring
  • Next re-runs the loader and waits for it: page 1 stays on screen with no loading flash until page 2 is ready
  • Next keeps the tag in the URL and the list

Loading the in-browser editor…

Further reading