TanStack Router: Fully Type-Safe Routing in React

Photo by Norfolks disused railways via Wikimedia Commons (CC BY 4.0)
Yes. Path params, search params, and loader data are all inferred through TypeScript when you use the route-scoped hooks like Route.useParams, Route.useSearch, and Route.useLoaderData. To extend that typing to global helpers like Link and useNavigate, you add a one-time Register declaration-merging block. After that the compiler catches invalid routes and params before runtime.
You declare a validateSearch function on the route, usually backed by a Zod schema, that parses and validates the raw search string into a typed object. Every hook and navigation call then sees the validated, typed shape. Using Zod's .catch() lets malformed params fall back to defaults silently instead of throwing errors at users.
File-based routing uses a plugin to generate the route tree and all type linkages from route files on disk, and it handles code splitting automatically. Code-based routing means you build the tree by hand, wiring each child to its parent with getParentRoute. Both are type-safe, but file-based is the recommended default and requires far less boilerplate.
A loader does not re-run on every search param change by default. You declare which search values the loader cares about using loaderDeps, and only those trigger a reload. This keeps caching correct and prevents unrelated filter changes from refetching data unnecessarily.
Often yes. Loaders are a data-fetching boundary that guarantees data is present when a route renders, but they are not a full cache layer. For request deduplication, background refetching, and mutations, pair loaders with TanStack Query — typically by calling queryClient.ensureQueryData inside the loader.

Photo by Norfolks disused railways via Wikimedia Commons (CC BY 4.0)
Key Takeaway
TanStack Router makes routing type-safe end to end: path params, URL search params, and loader data all flow through TypeScript inference. You validate search params once with a schema, loaders own async data fetching, and file-based routing auto-generates the route tree so the compiler catches broken links before runtime.
Most React routers treat the URL as an untyped string bag. You navigate to a path, read a param, and hope it exists and has the shape you assumed. On a service I run I lost an afternoon to a rename that left three components pointing at a route that no longer existed — the build passed, the page 404'd in production. TanStack Router is the first router I have used that turns that class of bug into a compile error.
It reached a stable v1 in late 2023 and has been steady in production since. In 2026 it is the router people reach for when they want TypeScript to actually understand their navigation, and it is the foundation under TanStack Start for server-side rendering. This post walks through the three things that make it different: typed params, validated search state, and loaders — then compares file-based and code-based routing so you can pick the right one.
When you define a route with a dynamic segment, the router infers the param name and type from the route definition. You do not annotate anything by hand, and you cannot read a param that the route does not declare. The hook is scoped to the route, so its return type is exactly the params that route owns — no optional-everything, no string-or-undefined guessing.
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostComponent,
})
function PostComponent() {
// postId is typed as string — inferred from the route path
const { postId } = Route.useParams()
return <div>Post {postId}</div>
}For shared components that render under several routes, pass the from option to a hook to pick which route's context you want, or use strict: false to accept the union of possible params. That keeps helper components reusable without losing all type information.
This is the feature that changed how I think about URLs. Search params — the part after the question mark — are usually the messiest state in a React app: pagination, filters, sort order, all serialized as strings and re-parsed everywhere. TanStack Router treats the search string as a first-class, typed object. You declare a validateSearch schema on the route, and from then on the parsed, validated object is what every hook and Link sees.
// src/routes/shop.products.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().catch(1),
filter: z.string().catch(''),
sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
})
export const Route = createFileRoute('/shop/products')({
validateSearch: (search) => productSearchSchema.parse(search),
component: Products,
})
function Products() {
// sort is 'newest' | 'oldest' | 'price', not a loose string
const { page, filter, sort } = Route.useSearch()
// ...
}The use of .catch() instead of .default() is deliberate. .catch() means a malformed or missing param silently falls back to the fallback value instead of throwing a validation error at the user. A shared link with a garbage sort value still renders; it just uses the default. That is almost always what you want for URL state that strangers can edit by hand.
A route loader hoists async work out of the component. Instead of a useEffect that fires after render, the loader runs as part of resolving the route — in parallel with sibling loaders, with caching, and with preloading when the user shows intent by hovering a link. The loader owns the data boundary, and its return value is inferred into useLoaderData with no manual typing.
The subtle part is how loaders and search params connect. A loader does not automatically re-run when any search param changes — that would refetch on every unrelated filter toggle. You declare which search values the loader depends on with loaderDeps, and only those trigger a reload. This keeps caching correct and avoids the classic waterfall of over-fetching.
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
offset: z.number().int().nonnegative().catch(0),
}),
// only `offset` re-triggers the loader
loaderDeps: ({ search: { offset } }) => ({ offset }),
loader: async ({ deps: { offset } }) => fetchPosts({ offset }),
component: Posts,
})
function Posts() {
const posts = Route.useLoaderData() // typed as the return of fetchPosts
// ...
}Loaders are a data-fetching boundary, not a full cache layer. For request dedup, background refetch, and mutations you still want TanStack Query. The common pattern is a loader that calls queryClient.ensureQueryData so the route guarantees data is present while Query owns the cache lifecycle.
TanStack Router supports both defining routes as files on disk and building the route tree by hand in code. Both are fully type-safe, but they get there differently. File-based routing uses a plugin that reads your route files and generates the route tree plus all the type linkages for you. Code-based routing means you construct the tree yourself, wiring each child to its parent with getParentRoute so the child inherits the parent's types.
| Aspect | File-based routing | Code-based routing |
|---|---|---|
| Route tree | Generated automatically by the plugin | Assembled by hand via children arrays |
| Type linkage | Wired for you; less boilerplate | You manage getParentRoute manually |
| Code splitting | Automatic per route file | Manual, you arrange lazy imports |
| Discoverability | URL structure mirrors the file tree | Lives wherever you put it in code |
| Best for | Most apps; the recommended default | Dynamic/programmatic route generation |
The official guidance is unambiguous: file-based routing is the recommended default, and it is what TanStack Start uses. It raises the type-safety ceiling because the generator produces linkages that would be tedious and error-prone to maintain by hand. I reach for code-based routing only when routes must be generated dynamically at runtime — a plugin system, say — where files on disk cannot describe the tree. For everything else, let the plugin do the wiring.
Typed params and loaders work inside a route without extra ceremony, but to make top-level exports like Link and useNavigate fully typed against your route tree, you register the router with declaration merging. This is the single line that pipes your app's exact route types into the global navigation helpers, so an invalid to prop on a Link becomes a red squiggle in your editor.
// src/main.tsx
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}After registration, refactoring a route path is safe: rename the file, run the generator, and every broken Link and useNavigate call lights up in TypeScript. That is the whole promise — the router understands your URLs well enough to fail the build instead of shipping a 404.
If you have been fighting stringly-typed navigation and useEffect data fetching, TanStack Router is worth the migration cost. Start with file-based routing, validate every route's search params with a schema, move data fetching into loaders wired through loaderDeps, and add the Register block. The payoff is a codebase where the URL is real, typed state — and where the compiler, not production, tells you when a link breaks.