If you’ve ever reached for useSearchParams from React Router and found yourself writing five lines of manual casting just to read a single integer from the URL, you know the pain. The entire ecosystem around client-side routing in React has spent years pretending that URLs are not part of your type system. TanStack Router decided that’s unacceptable — and built a code generation pipeline that makes every path param, search param, and piece of router context fully typed, validated, and autocompleted.
This article is the guide I wish existed when I first dug into TanStack Router’s type generation. We’ll go from zero to a production-grade setup: file-based routing, auto-generated types, Zod-validated search params, typed path params, and a router context that carries your auth and query client without any as casts.
Official repo: github.com/TanStack/router
Why TanStack Router’s Type Generation Actually Matters
The usual pitch for type-safe routing sounds academic until you’ve been bitten. Consider these real scenarios:
- A search page reads
?page=from the URL, but someone navigates with?page=foo. Your code crashes at runtime because you typed it asnumberand trusted the URL. - A detail page reads
/users/:id, and you accidentally pass it to an API call that expects a UUID — butidis typed asstring, so TypeScript shrugs. - You refactor a route path from
/dashboard/settingsto/settings, and you have no idea how many<Link to="/dashboard/settings">calls are now broken.
TanStack Router solves all three. The file-based router + Vite plugin watches your route files and regenerates a routeTree.gen.ts on every save. Every <Link>, every useParams, every useSearch call is checked against the generated tree. If the route doesn’t exist or the params don’t match, TypeScript tells you before the browser does.
Project Setup
Start with a fresh Vite + React + TypeScript project, then install:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin
Wire up the Vite plugin in vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
// TanStackRouterVite must come before react()
TanStackRouterVite({ routesDirectory: './src/routes' }),
react(),
],
})
Create src/routes and drop in a root route file:
src/
routes/
__root.tsx
index.tsx
users/
$userId.tsx
index.tsx
The plugin generates src/routeTree.gen.ts automatically whenever you add or modify files under routes/. Commit routeTree.gen.ts to version control — it’s generated but it’s load-bearing, and you want CI to be able to build without running the dev server.
Gotcha: If you run
tsc --noEmitin CI without first running the Vite build or the standalone@tanstack/router-cli, the generated file won’t exist and TypeScript will fail. Add"scripts": { "generate-routes": "tsr generate" }and run it as a pre-build step.
Bootstrap the router in src/main.tsx:
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
// TypeScript augmentation — required once per project
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />
)
That Register augmentation is what makes all the downstream inference work. Without it, every <Link> would accept any string.
Typed Path Params
File-based routing uses $paramName in the filename to declare a dynamic segment. src/routes/users/$userId.tsx maps to /users/:userId.
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
component: UserDetail,
})
function UserDetail() {
// params is fully typed: { userId: string }
const { userId } = Route.useParams()
return <h1>User {userId}</h1>
}
userId is typed as string because URL segments are always strings at the transport layer. If you need a number, parse it in loaderDeps or beforeLoad — don’t trust the URL to carry semantic types for you.
You can go deeper. Nested routes stack their params:
routes/
orgs/
$orgId/
teams/
$teamId.tsx → /orgs/:orgId/teams/:teamId
Inside $teamId.tsx, Route.useParams() returns { orgId: string; teamId: string } — both parent and child params, fully merged.
Navigation is also checked:
// This will error if userId is not a string — and if the route doesn't exist
<Link to="/users/$userId" params={{ userId: user.id }}>
View profile
</Link>
Change the route filename and every <Link to="/users/$userId"> that used the old path breaks at compile time. This is the refactoring story that React Router can’t tell.
Search Params — Where It Gets Interesting
Search params are the messiest part of URL state. They’re optional, untyped by default, and shared across the entire URL with no ownership. TanStack Router makes you declare them explicitly per route, with a validator function.
Basic Search Params
// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
query: z.string().optional(),
role: z.enum(['admin', 'user', 'viewer']).optional(),
})
export const Route = createFileRoute('/users/')({
validateSearch: (search) => searchSchema.parse(search),
component: UserList,
})
function UserList() {
// { page: number; query?: string; role?: 'admin' | 'user' | 'viewer' }
const { page, query, role } = Route.useSearch()
return (
<div>
<p>Page {page}</p>
{query && <p>Filtering by: {query}</p>}
</div>
)
}
validateSearch runs every time the search string changes. If the user manually types ?page=foo, Zod’s .default(1) falls back to 1 — no crash, no NaN. If you use .parse(), invalid input throws and TanStack Router catches it at the errorComponent. If you want silent coercion, use .catch() chained on each field instead.
Gotcha:
z.number()from Zod does not coerce strings. The URL delivers?page=2as the string"2", not the number2. You needz.coerce.number()orz.number().transform(Number). Forgetting this is probably the most common early-stage mistake with TanStack Router.
// Correct — coerces "2" → 2
const searchSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
})
Updating Search Params Without Full Navigation
function UserList() {
const navigate = useNavigate({ from: '/users/' })
const { page } = Route.useSearch()
return (
<button onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })
}>
Next page
</button>
)
}
The functional updater form means you never accidentally clobber other search params that belong to the same route. The types flow through — TypeScript knows prev has the shape from your schema, and it will tell you if you try to set page to a string.
Search Param Inheritance and Sharing
By default, each route owns its own search params independently. If a child route needs to read a param declared in a parent, it needs to declare it again — or use search inheritance.
For cases where you genuinely want global search state (like a theme toggle or a locale), declare it on the root route:
// src/routes/__root.tsx
import { createRootRoute } from '@tanstack/react-router'
import { z } from 'zod'
const rootSearchSchema = z.object({
locale: z.enum(['en', 'ru', 'de']).default('en'),
})
export const Route = createRootRoute({
validateSearch: (search) => rootSearchSchema.parse(search),
})
Every child route will now include locale in its search type. Navigating from any route with search: (prev) => ({ ...prev, locale: 'ru' }) works without knowing which route you’re on.
Router Context — Dependency Injection Without the Framework Tax
This is the feature that turns TanStack Router from a routing library into a proper application shell. Router context lets you pass shared singletons — auth state, query clients, feature flags — into the route tree, where every beforeLoad, loader, and component can access them with full types.
Defining the Context
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import type { QueryClient } from '@tanstack/react-query'
export interface RouterContext {
queryClient: QueryClient
auth: {
user: { id: string; role: string } | null
isAuthenticated: boolean
}
}
export const router = createRouter({
routeTree,
context: {
// These are the "startup defaults" — real values come from RouterProvider
queryClient: undefined!,
auth: undefined!,
},
})
The undefined! trick is deliberate: TypeScript sees the types you declared, but the actual values are injected at runtime. You’re not lying to the compiler — you’re deferring initialization to RouterProvider.
Injecting Real Values
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import { useAuth } from './hooks/useAuth'
const queryClient = new QueryClient()
function App() {
const auth = useAuth() // your auth hook
return (
<QueryClientProvider client={queryClient}>
<RouterProvider
router={router}
context={{ queryClient, auth }}
/>
</QueryClientProvider>
)
}
Every time auth changes (login/logout), the context updates and routes that depend on it re-evaluate.
Using Context in Routes
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import type { RouterContext } from '../router'
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
})
Note createRootRouteWithContext instead of createRootRoute — this is what binds the context type to the entire tree.
Now in any route’s beforeLoad or loader:
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// context.auth is fully typed: { user: ..., isAuthenticated: boolean }
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
loader: ({ context }) => {
// context.queryClient is a fully typed QueryClient
return context.queryClient.fetchQuery(dashboardQuery())
},
component: Dashboard,
})
No prop drilling, no context wrappers, no as AuthContext casts. The types come from the definition you made once in router.ts.
Gotcha:
beforeLoadruns on every navigation to that route, including back/forward navigation. If your context check throws a redirect on every visit, you’ll get an infinite loop. Make sure your redirect target doesn’t have the samebeforeLoadcheck.
Context in Components
Components can also access context, but they should prefer the route-level APIs when possible:
function Dashboard() {
// Use the route's loader data — don't re-fetch in the component
const data = Route.useLoaderData()
// If you genuinely need context in the component:
const { auth } = Route.useRouteContext()
return <div>Welcome, {auth.user?.id}</div>
}
The useRouteContext() hook is scoped to the current route — it knows exactly which context shape to return based on where you call it.
Production-Ready Patterns
Structuring Validation with Fallbacks
For user-facing search params, prefer .catch() over .parse() — you want the app to keep working when someone shares a link with a malformed query string, not throw a full error boundary:
const searchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
sort: z.enum(['asc', 'desc']).catch('asc'),
query: z.string().max(200).optional().catch(undefined),
})
// Use safeParse in validateSearch for maximum control
validateSearch: (raw) => {
const result = searchSchema.safeParse(raw)
return result.success ? result.data : searchSchema.parse({})
}
Generating Routes in CI
Add this to your CI pipeline before tsc runs:
# .github/workflows/ci.yml
- name: Generate route tree
run: npx tsr generate
- name: Type check
run: npx tsc --noEmit
If someone adds a route file without committing the generated tree, CI catches it. If someone manually edits routeTree.gen.ts, the regenerated file will overwrite it and the diff will be obvious.
Avoid Fat Context
The router context is not a Redux store. Keep it to singletons and session-level state:
- QueryClient — yes
- Auth state — yes
- Feature flag client — yes
- Paginated table state — no, use search params or component state
Fat context re-evaluates every route on every context change. Keep it lean.
Gotchas Summary
Coercion: z.number() won’t parse URL strings. Use z.coerce.number() for any numeric search param or path segment you parse manually.
routeTree.gen.ts in CI: Always generate before type-checking in automated pipelines. The file doesn’t exist until the plugin or CLI runs.
beforeLoad loops: Redirect guards in beforeLoad can cause infinite loops if the redirect target has the same guard. Guard only protected routes and make sure your login/public routes are genuinely unguarded.
Context defaults: The context object passed to createRouter is just for TypeScript inference. The real values come from RouterProvider‘s context prop. Never depend on the defaults at runtime.
Search param ownership: A child route that doesn’t declare a search param gets never for that key when calling useSearch. If multiple routes share a param, each must declare it — or move it to the root.
validateSearch is synchronous: You can’t await an API call in validateSearch. If validation depends on async data (e.g., checking if an enum value exists in the DB), do it in beforeLoad or loader instead.
Wrapping Up
TanStack Router’s code generation is one of the few cases in frontend development where the tooling genuinely earns its complexity. You write route files, the plugin generates the type tree, and from that point on the compiler knows everything: what routes exist, what params they take, what search state they own, and what context flows through them.
The mental shift is treating the URL as a first-class typed interface rather than a string you parse manually. Once you’ve worked with a codebase where <Link to="/users/$userId"> catches typos at compile time and useSearch() returns a validated, coerced object with sensible defaults, going back to manual URLSearchParams parsing feels like writing PHP without type hints.
Start with the Vite plugin, define your root context early, put Zod coercion on every numeric search param, and let the generated routeTree.gen.ts do the rest.