Routing with TanStack Router: Treat the URL Like Application State

The policy list bug started as a support ticket.
An agent filtered policies by status, page, and renewal date. She copied the URL and sent it to another agent. The second agent opened it and saw a different list.
Same page. Same product. Different state.
The filters were not in the URL. The page number lived in local component state. The selected tab lived in a store. The search box was debounced into a fetch call, but never synchronized back to navigation. The route looked shareable. It wasn't.
Honestly, that bug changed how I think about routing.
Routing is not just "which component renders for this path." That is the small version. In product software, routing is about preserving intent. What screen is the user on? Which record? Which filters? Which tab? Which page? Can they reload and get the same view? Can they send the link to someone else and trust it?
I've seen teams treat the URL as decoration. Then they spend months rebuilding browser behavior manually.
TanStack Router gives React apps a better option: typed routes, typed params, typed search, loaders, redirects, preloading, and integration with TanStack Query. The point is not fancy routing. The point is making navigation state explicit.
A URL is not a string you push into history. It is part of your application's state model.
This is article #6 in the React Playbook series. We already covered the stack, TypeScript patterns, code structure, error handling, and TanStack Query. Now we connect those pieces through routing.
Why TanStack Router
React Router is familiar. It works. I used it for years.
The reason I reach for TanStack Router in new TypeScript-heavy apps is not that React Router is bad. It is that TanStack Router makes more route mistakes impossible.
With TanStack Router, routes are registered into a typed route tree. That means:
<Link to="/policies/$policyId">knows which params are required.useParams({ from: '/policies/$policyId' })returns the right param type.useSearch({ from: '/policies' })returns validated search state.navigate({ to, params, search })is checked against the actual route.- Loaders can use route context and params safely.
The difference is subtle until a route changes.
Rename $policyId to $id, and TypeScript tells you everywhere the old param is still used. Remove a search param, and navigation calls fail to compile. That is the kind of friction I want.
Not because I enjoy types for their own sake.
Because broken links and stale URL assumptions are production bugs.
File-Based or Code-Based Routes?
TanStack Router supports both file-based and code-based routing.
For large teams, I like file-based routing. It creates a predictable structure, works well with route code splitting, and keeps route files close to pages.
For teaching and small early projects, code-based routing is easier to see in one place.
This article uses a file-based shape because it is what I would use once the app grows:
src/ routes/ __root.tsx index.tsx policies/ index.tsx $policyId.tsx $policyId.edit.tsx routeTree.gen.ts
In an FSD-style project, I still keep page UI in pages/ and let route files wire it:
src/ routes/ policies/ index.tsx $policyId.tsx pages/ policy-list/ PolicyListPage.tsx policy-detail/ PolicyDetailPage.tsx
Route files are integration points. They connect URL shape, loader behavior, search validation, and page components. They should not become business logic containers.
The Root Route
The root route is where app-level layout and route context begin.
// src/routes/__root.tsx import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; import type { QueryClient } from '@tanstack/react-query'; import { RootLayout } from '@/app/RootLayout'; interface RouterContext { queryClient: QueryClient; auth: AuthSession | null; } export const Route = createRootRouteWithContext<RouterContext>()({ component: RootRoute, }); function RootRoute() { return ( <RootLayout> <Outlet /> </RootLayout> ); }
That context matters. Later, route loaders can access queryClient without importing a singleton. Auth checks can use the current session. Tests can provide a different context.
Then create the router:
// src/app/router.ts import { createRouter } from '@tanstack/react-router'; import { routeTree } from '@/routeTree.gen'; import { queryClient } from './query-client'; import { authSession } from './auth-session'; export const router = createRouter({ routeTree, context: { queryClient, auth: authSession, }, }); declare module '@tanstack/react-router' { interface Register { router: typeof router; } }
The Register declaration is the bridge that gives the rest of the app type-safe navigation. Skip it and you lose much of the point.
Route Params Should Be Boring
A policy detail route:
// src/routes/policies/$policyId.tsx import { createFileRoute } from '@tanstack/react-router'; import { PolicyDetailPage } from '@/pages/policy-detail'; export const Route = createFileRoute('/policies/$policyId')({ component: PolicyDetailRoute, }); function PolicyDetailRoute() { const { policyId } = Route.useParams(); return <PolicyDetailPage policyId={policyId} />; }
policyId is typed from the route path. No manual generic. No as string. No "I hope this param exists."
Navigation is typed too:
import { Link } from '@tanstack/react-router'; export function PolicyRow({ policy }: { policy: Policy }) { return ( <Link to="/policies/$policyId" params={{ policyId: policy.id }}> {policy.policyNumber} </Link> ); }
If you forget policyId), TypeScript complains. If you pass idinstead ofpolicyId`, TypeScript complains. That is boring in the best possible way.
Search Params Are Product State
The policy list route is where TanStack Router starts to feel different.
Filters belong in the URL:
/policies?status=active&page=2&query=miller
Not because URLs are pretty. Because users reload pages, bookmark views, share links, and go back in browser history.
Define the search shape:
// src/routes/policies/index.tsx import { createFileRoute } from '@tanstack/react-router'; import { z } from 'zod'; import { PolicyListPage } from '@/pages/policy-list'; const policyListSearchSchema = z.object({ status: z.enum(['active', 'expired', 'pending']).optional(), page: z.number().int().positive().catch(1), query: z.string().catch(''), }); export const Route = createFileRoute('/policies/')({ validateSearch: policyListSearchSchema, component: PolicyListRoute, }); function PolicyListRoute() { const search = Route.useSearch(); return <PolicyListPage filters={search} />; }
Now search.page is a number. Not a string you parsed manually. Not string | undefined. A number.
This matters because search params are the easiest place for type lies to enter an app. The browser gives you strings. Your product wants domain state. TanStack Router gives you a validation boundary.
Updating Search Without Losing State
When the user changes a filter:
import { useNavigate } from '@tanstack/react-router'; export function PolicyFilters({ filters }: { filters: PolicyListFilters }) { const navigate = useNavigate({ from: '/policies/' }); return ( <StatusSelect value={filters.status} onChange={(status) => { navigate({ search: (prev) => ({ ...prev, status, page: 1, }), }); }} /> ); }
Notice the page: 1.
Changing status should reset pagination. This is a product rule. Put it where navigation state changes. Otherwise you end up on page 7 of a filter that only has one page and someone files a "missing policies" bug.
For pagination:
export function PolicyPagination({ page }: { page: number }) { const navigate = useNavigate({ from: '/policies/' }); return ( <Pagination page={page} onPageChange={(nextPage) => { navigate({ search: (prev) => ({ ...prev, page: nextPage, }), }); }} /> ); }
No local useState. The URL is the state.
Connecting Router to Query
Article #5 covered query options. Routing is where they pay off.
// src/routes/policies/index.tsx export const Route = createFileRoute('/policies/')({ validateSearch: policyListSearchSchema, loaderDeps: ({ search }) => ({ search }), loader: ({ context, deps }) => context.queryClient.ensureQueryData(policiesQueryOptions(deps.search)), component: PolicyListRoute, });
The route validates search. The loader uses validated search to warm the query cache. The page subscribes to the same query:
function PolicyListRoute() { const search = Route.useSearch(); const policiesQuery = useQuery(policiesQueryOptions(search)); return <PolicyListPage filters={search} policiesQuery={policiesQuery} />; }
One data definition. Two usages.
That is the shape I want: route loaders handle navigation-level data readiness; components still use Query for cache subscription, background refetching, and UI state.
Don't fetch in the loader and pass raw data into the component while also using Query elsewhere. That creates two data paths. Use the loader to fill the cache.
Detail Routes and Not Found
For detail pages:
// src/routes/policies/$policyId.tsx import { createFileRoute, notFound } from '@tanstack/react-router'; import { policyDetailQueryOptions } from '@/entities/policy'; import { isNotFoundError } from '@/shared/api'; import { PolicyDetailPage } from '@/pages/policy-detail'; export const Route = createFileRoute('/policies/$policyId')({ loader: async ({ context, params }) => { try { return await context.queryClient.ensureQueryData( policyDetailQueryOptions(params.policyId), ); } catch (error) { if (isNotFoundError(error)) throw notFound(); throw error; } }, component: PolicyDetailRoute, notFoundComponent: PolicyNotFound, errorComponent: PolicyRouteError, }); function PolicyDetailRoute() { const { policyId } = Route.useParams(); const policyQuery = useQuery(policyDetailQueryOptions(policyId)); return <PolicyDetailPage policy={policyQuery.data} />; }
The route decides whether the page can exist. A missing policy is not the same as a temporary server failure. This lines up with the error-handling model from article #4.
Route Guards with beforeLoad
Some routes require authentication or permissions.
Use beforeLoad for route-level guards:
// src/routes/admin.tsx import { createFileRoute, redirect } from '@tanstack/react-router'; import { AdminPage } from '@/pages/admin'; export const Route = createFileRoute('/admin')({ beforeLoad: ({ context, location }) => { if (!context.auth) { throw redirect({ to: '/login', search: { redirectTo: location.href, }, }); } if (context.auth.role !== 'admin') { throw redirect({ to: '/policies' }); } }, component: AdminPage, });
This is not a replacement for backend authorization. Never trust the frontend for security.
But it is the right place to keep users out of UI they cannot use. It also preserves intent: after login, send them back to where they tried to go.
Preloading on Intent
Routing should feel instant when intent is clear.
TanStack Router can preload routes through links. TanStack Query can prefetch query data. Use both deliberately.
export function PolicyRow({ policy }: { policy: Policy }) { return ( <Link to="/policies/$policyId" params={{ policyId: policy.id }} preload="intent" > {policy.policyNumber} </Link> ); }
When the user hovers or focuses the link, the router can prepare the destination. If your route loader calls ensureQueryData, the query cache warms too.
That is the clean integration: link intent -> route preload -> loader -> query cache.
The user clicks, and the page is already halfway there.
Use Route APIs Inside Route Components
TanStack Router gives each route a Route object. Use it.
function PolicyListRoute() { const search = Route.useSearch(); const navigate = Route.useNavigate(); // ... }
This is more precise than global hooks when you know which route you are in. The types are anchored to that route. Refactors stay local.
In leaf page components, I still prefer passing explicit props:
function PolicyListRoute() { const search = Route.useSearch(); const policiesQuery = useQuery(policiesQueryOptions(search)); return <PolicyListPage filters={search} policiesQuery={policiesQuery} />; }
Why not let PolicyListPage call Route.useSearch() directly?
Sometimes that is fine. But I like keeping page components easier to test and less coupled to router internals. The route knows about routing. The page knows about rendering the product view.
Don't Hide Navigation in Random Helpers
One pattern I avoid:
export function openPolicy(policyId: string) { router.navigate({ to: '/policies/$policyId', params: { policyId } }); }
It looks convenient. It spreads navigation decisions into random helpers. Over time, you get hidden redirects, surprising history behavior, and navigation that bypasses route-local search state.
Prefer explicit links and route-local navigation. If a navigation pattern repeats, create a component:
export function PolicyLink({ policyId, children, }: { policyId: string; children: React.ReactNode; }) { return ( <Link to="/policies/$policyId" params={{ policyId }} preload="intent"> {children} </Link> ); }
Now navigation stays visible in the UI tree.
The Review Checklist
When I review routing code, I ask:
- Is shareable product state in the URL?
- Are search params validated at the route boundary?
- Do search changes preserve or reset related state intentionally?
- Do route loaders reuse TanStack Query options instead of inventing separate fetch paths?
- Are detail routes handling not-found separately from server errors?
- Are protected routes guarded in
beforeLoad? - Are links using typed
to,params, andsearchinstead of string concatenation? - Is preloading used where user intent is clear?
- Are page components receiving explicit props when that keeps them easier to test?
Most routing bugs are not about paths. They are about state that should have been in the URL but wasn't.
What Comes Next
With Query and Router in place, the next question is client state: the data that does not live on the server but still becomes too complex for useState.
Article #7 is about Reatom: why I use it in CRM-scale React apps, where it fits next to TanStack Query, and why I don't reach for Zustand by default in this particular stack.
After the policy-list support ticket, we moved filters, page, and search into route search params. The bug disappeared. More importantly, the product started behaving like a web app again. Refresh worked. Back worked. Shared links worked. QA could send exact states to developers instead of screenshots and vague reproduction steps.
That is what routing should do.
Not just render components.
Preserve intent.
If your React app has pages that look shareable but aren't, start with the URL. The fix is often not another store. It is admitting that the route already owns more state than your code says it does.