TanStack Router: typed routes, search state and loaders
- Build a typed route tree — file-based (
createFileRouteplus a generated tree) or code-based (createRoute,getParentRoute,addChildren) — and register the router soLinkandnavigateare type-checked - Write a
validateSearchthat 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:
loaderDepsfor the search values the loader uses, and a loader that fills the query cache (queryClient.query({ …, staleTime: 'static' }), formerlyensureQueryData) foruseSuspenseQueryto read - Predict what the user sees on a navigation: when the loader re-runs, when the pending component appears, and what a missing
loaderDepsdoes
Read
A route tree TypeScript can check
FlagTanStack 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.
The loader fills the query cache; the component reads it
FlagFetching 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;createRoutermust then be given aqueryClient, and every loader receives it ascontext.queryClient.loaderDepslists the search values the loader uses. The loader reads them fromdeps, and when they change between navigations the router re-runs the loader — "regardless ofstaleTime", in the docs' words. Return only what the loader uses: returning the wholesearchreloads 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 replacedqueryClient.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 thequeryFnonce for two loads; a plainquery(options)(defaultstaleTime: 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. Itsdatais neverundefined.defaultPreloadStaleTime: 0— the router caches loader results too (preloads are fresh for 30 seconds by default). The docs recommend0with 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.
Where the router and the query cache trip each other up
FlagChecked 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?
In code-based routing, why does every non-root createRoute need getParentRoute: () => parentRoute?
?page=2 arrives in validateSearch as the number 2. Why validate at all?
On /posts?page=1&tag=react, the Next link is <Link from="/posts" search={{ page: page + 1 }}>. Where does it go?
A /posts loader reads page from location.search and has no loaderDeps. The user clicks from page 1 to page 2. What happens?
Which loader body matches what ensureQueryData did, in TanStack Query v5.102+?
With default router options and loaderDeps in place, the user clicks Next and page 2's loader takes 3 seconds. What do they see?
Fill in the blank
Declare a code-based child route and build the tree.
Flagconst postsRoute = createRoute({ : () => rootRoute, path: "posts", }); const routeTree = rootRoute.([postsRoute]);
Validate the search: a page that isn't a positive integer falls back to 1.
FlagvalidateSearch: (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.
: ({ 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;
},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.// 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>Challenge
Validate the search params and wire them into the loader
FlagA 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=abcsends"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=reactrenders page 2 of the react posts- A
pagethat 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
tagin the URL and the list
Further reading
- TanStack Router v1 docs: Code-Based Routing
- TanStack Router v1 docs: File-Based Routing
- TanStack Router v1 docs: Type Safety
- TanStack Router v1 docs: Search Params
- TanStack Router v1 docs: Data Loading
- TanStack Router v1 docs: External Data Loading
- TanStack Query v5 docs: Migrating to v5 — Imperative QueryClient methods