@tanstack/react-router
TanStack Router is a client-first React router built around the idea that TypeScript should know your entire route tree. Every path, path param, search param, loader return value, and piece of route context is inferred, so a Link to a route that does not exist, or one missing a required param, is a compile error rather than a broken page. Routes are normally files under src/routes that a build plugin compiles into a generated routeTree.gen.ts, though you can also build the tree in code with createRoute. Each route can declare a loader for data, a beforeLoad guard that runs before its children, validateSearch to parse and type the query string, and its own pending, error, and not-found components. Search params are treated as structured JSON state with validation rather than as strings, and the router has built-in caching, preloading on hover or intent, and a devtools panel. TanStack Start is the full-stack framework layered on top of it.
For a TypeScript SPA where URLs carry real state, this is the best-typed router available and the search param handling alone justifies it. Budget for the learning curve, the generated route tree in your repo, and the TypeScript check time the docs themselves warn about.
Use it if
- You want navigation mistakes caught at compile time: a Link to a path that no longer exists, or one missing a required path param, fails the type check instead of producing a dead link
- Your URLs carry real state: search params are parsed as JSON, validated by a zod or valibot schema per route, and typed everywhere you read them, which is far better than passing strings around
- You want data loading attached to routes with loaders, preloading on hover, and a documented integration where the loader calls queryClient.ensureQueryData and the component uses useSuspenseQuery
- You are building a client-rendered application rather than adopting a server framework, and want a router you can deploy as static files while keeping routing and data logic on the client
- You are not using TypeScript. Nearly all of the value is type inference; in a JavaScript codebase you get the API complexity and none of the payoff, and wouter or react-router will be far less machinery
- Your editor is already slow. The type-safety guide has a Performance Recommendations section that says check times grow with the route tree, that a Link with search but no from or to is checked against a union of every route's search params, and that loaders should avoid inferring return types you never use. These are real costs on a large app
- You need server rendering out of the box: this package is the router, and the full-stack story is TanStack Start, a separate and much newer project. Next.js and React Router v7 already ship SSR, streaming, and deployment
- You are migrating a large react-router app: the model is different enough (generated route tree, curried createFileRoute, route context, JSON search params) that a migration guide exists and it is still a rewrite of the routing layer
- You dislike generated files in source control: file-based routing requires a bundler plugin, writes routeTree.gen.ts into src, and the FAQ tells you to commit it while the setup docs tell you to exclude it from your linter, formatter, file watcher, and search results
- You want a slow-moving dependency: the version is already 1.170.x with releases landing most days, plus alpha, beta, and pre dist-tags. Nothing here breaks semver, but you are tracking a fast stream
Setup reality
npm install @tanstack/react-router plus @tanstack/router-plugin as a dev dependency, with React 18 or 19 as a peer and Node 20.19 or newer. In vite.config.ts, tanstackRouter({ target: 'react', autoCodeSplitting: true }) must be listed before @vitejs/plugin-react or the transform runs on the wrong output; the docs call this out explicitly and it is the first thing that breaks. The plugin watches src/routes and writes src/routeTree.gen.ts, which you commit but exclude from Prettier, ESLint, and your editor's watcher (the docs suggest marking it readonly in VS Code because renaming a route makes it pop open full of transient errors). The step people skip is the declaration merge: without declare module '@tanstack/react-router' { interface Register { router: typeof router } } you get no route-aware types at all, and Link props degrade to unhelpful unions. The path string in createFileRoute('/posts/$postId') is inserted and kept in sync by the generator, so editing it by hand fights the tool. File naming carries meaning that is not obvious on day one: $param for dynamic segments, $ for splat, _prefix for pathless layouts, trailing_ underscore to break out of a layout, route.tsx for a directory's layout, index.tsx for its exact path.
Patterns
Enable file-based routing in Vitevite-plugin-setup
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
react(),
],
})Order matters: the router plugin has to come before the React plugin. Defaults are routesDirectory ./src/routes and generatedRouteTree ./src/routeTree.gen.ts, so most projects pass nothing else.
Create the router and register its typescreate-and-register-router
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}The declare module block is not optional. Skip it and every Link, useParams, and useSearch loses its route awareness, which shows up as vague union types rather than a clear error.
Define the root route and render the treeroot-route
// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">Home</Link> <Link to="/posts">Posts</Link>
</nav>
<Outlet />
</>
),
})
// src/main.tsx
// <RouterProvider router={router} />__root.tsx is always rendered and cannot be conditionally skipped; the FAQ says to use a pathless layout route for anything conditional. Forgetting Outlet is why child routes render nothing.
Load data for a routeroute-with-loader
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostPage,
})
function PostPage() {
const post = Route.useLoaderData()
const { postId } = Route.useParams()
return <article>{post.title}</article>
}Route.useLoaderData is typed from the loader with no generics needed. The export must be named Route or the generator will not pick the file up, and the path string argument is maintained by the generator.
Link with params and searchtyped-links
import { Link } from '@tanstack/react-router'
<Link to="/posts/$postId" params={{ postId: '42' }}>
Read post
</Link>
<Link from={Route.fullPath} to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
Next page
</Link>Passing search without from or to makes TypeScript check your object against the union of every route's search params, which the docs name as a main cause of slow type checking. Narrow with from whenever you can.
Type and validate the query stringvalidate-search-params
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: productSearchSchema,
component: Products,
})Use .catch() rather than .default() unless you want a malformed URL to render the error component; a thrown validation error sets error.routerCode to VALIDATE_SEARCH. With Zod 3 you need @tanstack/zod-adapter for defaults to type correctly, with Zod 4 the schema works directly.
Read search params and navigate with new onesread-and-update-search
function Products() {
const { page, filter } = Route.useSearch()
const navigate = Route.useNavigate()
return (
<button
onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })
}
>
Page {page}
</button>
)
}The updater function form keeps the other params instead of wiping them, which the object form does. Search values are JSON, so nested objects and arrays survive the round trip through the URL.
Protect a subtree with beforeLoadauth-guard
// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
if (!(await isAuthenticated())) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})redirect is thrown, not returned, and beforeLoad runs before every child route's beforeLoad. The docs warn this is a UI gate only: the server still has to authorize each request, because endpoints can be called without the route.
Pass dependencies through route contextrouter-context
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: () => <Outlet />,
})
// src/router.tsx
const router = createRouter({ routeTree, context: { queryClient } })Note the double call: createRootRouteWithContext<T>() returns the route factory. Context declared here is available and typed in every loader and beforeLoad below it, which is how you avoid importing singletons everywhere.
Prefetch in the loader, read with Suspensequery-integration
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
await queryClient.ensureQueryData(postQueryOptions(postId))
},
component: Post,
})
function Post() {
const { postId } = Route.useParams()
const { data } = useSuspenseQuery(postQueryOptions(postId))
return <h1>{data.title}</h1>
}Await without returning, so the loader infers Promise<void>. The docs single this out: returning the query data forces TypeScript to infer a type nobody reads and slows editor checks across large route trees.
Per-route error, pending, and not-found UIerror-and-not-found
import { createFileRoute, notFound } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) throw notFound()
return post
},
pendingComponent: () => <Spinner />,
errorComponent: ({ error, reset }) => (
<button onClick={reset}>{String(error)}</button>
),
notFoundComponent: () => <p>No such post</p>,
})notFound() is thrown like redirect. Without a notFoundComponent on the route the error bubbles to the nearest ancestor that has one, ending at the router's default.
Use a route's hooks from another fileroute-api-outside-route-file
import { getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/posts/$postId')
export function PostSidebar() {
const post = routeApi.useLoaderData()
const { postId } = routeApi.useParams()
return <aside>{postId}</aside>
}This gives a component in a different file the same typed hooks without importing the Route object and creating a circular import. The path string is checked against the generated tree, so a typo is a compile error.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-router | npm | You want the incumbent with the biggest ecosystem, an SSR story in the same package, and a gentler learning curve |
| wouter | npm | You need hooks-based routing in a couple of kilobytes and none of the loader, search param, or codegen machinery |
| @tanstack/react-start | npm | You like this router but also need server rendering, server functions, and deployment handled |
| next | npm | You want routing, server rendering, and the build system as one opinionated framework rather than assembled parts |