TanStack Router — the type-safe alternative
The capability React Router has no clean answer for is validated, typed search params, and it costs a generated route tree
React Router is the incumbent and the default this project teaches. TanStack Router is the alternative worth knowing, and its pitch fits in one sentence: every route path, every param, and every search-param shape is a first-class TypeScript citizen, so misspelling a route or passing the wrong param type is a compile error, not a runtime surprise. The capability that has no clean React Router equivalent is search params as validated, typed state — filters, pagination, and sort order living in the URL with a schema instead of stringly-typed useSearchParams parsing. The costs are real too: a generated route tree, a TypeScript-inference tax that grows with your route count, and a smaller ecosystem. This article is the honest case for when that trade pays off.
What it is
TanStack Router is a fully type-safe router for React — client-side by default, and full-stack through its sibling framework TanStack Start. Conceptually it covers the same ground as React Router's data mode: routes, nested layouts with <Outlet>, loaders that fetch before render, per-route pending and error UI, and code-splitting. What it adds is end-to-end inference — the compiler knows your whole route tree — and a genuinely different model for URL state.
The default authoring style is file-based routing: you write route files, and a Vite plugin generates a typed route tree from them. Code-based routing exists but the ecosystem and docs assume file-based. Because this project keeps React Router as its baseline (two routers in every walkthrough would double every example), this is the one article on TanStack Router — so it's framed as a decision, not a from-scratch tutorial. Where it overlaps React Router, it points at that article rather than re-teaching the shared concept.
How it works under the hood
The type safety is a generated route tree
The magic hinges on one odd-looking detail: createFileRoute('/products') takes its own file path as a string literal. You don't write that literal — the TanStack Router Vite plugin (or CLI) writes and maintains it, and generates a routeTree.gen.ts file describing every route. That generated tree plus a one-time module augmentation is what makes everything typed:
// main.tsximport { createRouter, RouterProvider } from "@tanstack/react-router";import { routeTree } from "./routeTree.gen"; // generated by the plugin
const router = createRouter({ routeTree });
// Register the router's types globally so Link/useParams/useSearch infer.declare module "@tanstack/react-router" { interface Register { router: typeof router; }}After Register, <Link to="/products"> only accepts paths the plugin has seen, Route.useParams() is typed to that route's dynamic segments, and Route.useSearch() is typed to its search schema — no useParams<{ id: string }>() casts anywhere. The path literal is what tells TypeScript which route file you're in; without it, inference would have nothing to anchor to.
Search-param validation
validateSearch receives the JSON-parsed but untyped search object (Record<string, unknown>) and returns a typed one. Pass it a Zod schema directly and you get validation plus inference. The choice between Zod's .catch() and .default() is a UX decision worth understanding: .catch(fallback) silently repairs a malformed param (a user pasting a broken URL still lands somewhere sensible), while .default() throws on missing/invalid input, which routes to the route's errorComponent (with error.routerCode === "VALIDATE_SEARCH"). Default to .catch() — halting a user with an error page because a query string was mangled is rarely what you want.
The honest cost: TypeScript inference
The same inference that makes this pleasant has a price. The search prop on <Link> resolves to a union of every route's search params, so the compiler's check against it grows with your route count. TanStack caches the work, but the first check on a large app is genuinely expensive, and editor responsiveness can suffer. A related trap: returning a loader's promise directly forces TypeScript to infer the loader's return type even when nothing reads it. The fix is to await and return nothing — covered in the walkthrough. These aren't dealbreakers, but "it's all typed" is not free, and a principal engineer picking this should know where the tax lands.
Basic usage
File-based routing needs the Vite plugin, which generates routeTree.gen.ts from your route files. It must be placed before @vitejs/plugin-react — the plugin errors on the wrong order:
// vite.config.tsimport { defineConfig } from "vite";import { tanstackRouter } from "@tanstack/router-plugin/vite";import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [ tanstackRouter({ target: "react", autoCodeSplitting: true }), // must come first react(), ],});Add routeTree.gen.ts to your tsconfig include and your linter's ignore list — it's generated, not hand-edited. Then a basic route, a dynamic route with typed params, and a typed link:
// routes/about.tsximport { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({ component: () => <h1>About</h1>,});// routes/users.$userId.tsx ($userId is a dynamic segment)import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/users/$userId")({ component: UserPage,});
function UserPage() { const { userId } = Route.useParams(); // typed as string — no cast return <p>User {userId}</p>;}// A typed link — the compiler rejects unknown paths and wrong param types.import { Link } from "@tanstack/react-router";
<Link to="/users/$userId" params={{ userId: "42" }}>Profile</Link>;Walkthrough — typed search-param filters + a Query loader
We'll build the one thing that shows off what React Router can't do as cleanly: a products list whose filters live in the URL as typed state, with a loader that prefetches into a TanStack Query cache. This is also where the routing article's "loader-prefetch-into-Query, with typed route context" pointer gets paid off.
Step 1 — Put the query client in the router context
Route context flows to every loader and is fully typed. Seed it with the query client so loaders can prefetch:
// main.tsximport { createRouter, RouterProvider } from "@tanstack/react-router";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();const router = createRouter({ routeTree, context: { queryClient } });
declare module "@tanstack/react-router" { interface Register { router: typeof router; }}
export function App() { return ( <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider> );}// routes/__root.tsx — declares the context shape for type-safetyimport { createRootRouteWithContext, Outlet } from "@tanstack/react-router";import type { QueryClient } from "@tanstack/react-query";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: () => <Outlet />,});Step 2 — A route with typed search params and a prefetching loader
The search schema is Zod; loaderDeps declares which search params the loader depends on (so it re-runs when they change); the loader prefetches into Query:
// routes/products.tsximport { createFileRoute, Link } from "@tanstack/react-router";import { useSuspenseQuery, queryOptions } from "@tanstack/react-query";import { z } from "zod";import { fetchProducts } from "@/api/products";
const productSearchSchema = z.object({ category: z.enum(["all", "books", "electronics"]).catch("all"), page: z.number().int().positive().catch(1), sort: z.enum(["name", "price"]).catch("name"),});
// A shared query definition — reused in the loader and the component.function productsQuery(search: z.infer<typeof productSearchSchema>) { return queryOptions({ queryKey: ["products", search], queryFn: () => fetchProducts(search), });}
export const Route = createFileRoute("/products")({ validateSearch: productSearchSchema, // Only re-run the loader when these search params change: loaderDeps: ({ search }) => search, loader: async ({ context: { queryClient }, deps }) => { // await + return nothing: don't force the loader's return type to infer. await queryClient.ensureQueryData(productsQuery(deps)); }, component: ProductsPage,});
function ProductsPage() { const search = Route.useSearch(); // fully typed: { category, page, sort } const { data: products } = useSuspenseQuery(productsQuery(search)); // warm cache
return ( <div> <nav> {/* Typed writes: the compiler enforces the search shape. */} <Link to="/products" search={(prev) => ({ ...prev, category: "books" })}> Books </Link> <Link to="/products" search={(prev) => ({ ...prev, sort: "price" })}> Sort by price </Link> </nav> <ul> {products.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> </div> );}Change a filter → the typed <Link> writes a valid search object → loaderDeps sees the change and re-runs the loader → ensureQueryData prefetches → the component's useSuspenseQuery reads the warm cache. The whole chain is typed end to end, and the URL is the single source of truth for the filter state — bookmarkable, shareable, back-button-correct, with zero manual string parsing.
Step 3 — A typed detail route
// routes/products.$productId.tsximport { createFileRoute } from "@tanstack/react-router";import { useSuspenseQuery, queryOptions } from "@tanstack/react-query";import { fetchProduct } from "@/api/products";
const productQuery = (id: string) => queryOptions({ queryKey: ["product", id], queryFn: () => fetchProduct(id) });
export const Route = createFileRoute("/products/$productId")({ loader: async ({ context: { queryClient }, params }) => { await queryClient.ensureQueryData(productQuery(params.productId)); }, component: () => { const { productId } = Route.useParams(); // typed string const { data } = useSuspenseQuery(productQuery(productId)); return <h1>{data.name}</h1>; },});Same pattern, params instead of search. The queryOptions helper is the seam that lets the loader (prefetch) and the component (read) share one query definition without drift.
Real-world patterns
Search params as typed application state — the headline. In React Router, URL state is useSearchParams returning strings you parse and cast by hand (the routing article owns that model). Here, validateSearch makes the URL a typed, validated store: filters, tabs, pagination, sort, and any shareable UI state get a schema, structural sharing (unchanged params keep their reference), and type-checked writes. If a piece of state belongs in the URL, this is a materially better model — and it's the strongest single reason to choose TanStack Router.
The Query integration. Router owns routing and when to load; Query owns the cache. The bridge is a shared queryOptions object: define it once, ensureQueryData(options) in the loader to prefetch, useSuspenseQuery(options) in the component to read. The loader guarantees data is in the cache before the component renders (no in-component waterfall), and Query owns staleness and background refetch afterward. Keep the state-placement spine: the loader is a prefetch, not a second cache — don't stash results in a store.
Preload on intent. createRouter({ defaultPreload: "intent" }) starts a route's loader on hover or focus, so the data is often ready by the time the user clicks — a latency win React Router matches only with manual prefetch.
Auth guards via beforeLoad + typed context. beforeLoad runs before the loader; throw a redirect to guard a subtree. Because the auth service lives in typed route context, guards read it without prop-drilling or a global:
// routes/_authenticated.tsx — a pathless layout that guards its childrenimport { createFileRoute, redirect, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated")({ beforeLoad: ({ context, location }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: "/login", search: { redirect: location.href } }); } }, component: () => <Outlet />,});Code-splitting and Devtools. Routes split automatically under file-based routing (or via .lazy.tsx), splitting the critical path (parse, validate, load) from non-critical UI (Suspense handles the boundaries). In a code-split component, use getRouteApi("/products") to recover the typed useSearch/useParams without threading from. The Router Devtools panel visualizes matches, loader state, and the cache.
When it beats React Router — and when it doesn't. Reach for TanStack Router when the app is TypeScript-heavy, when significant UI state belongs in the URL (dashboards, search, filters), or when you're already all-in on TanStack Query and want the loader integration to be first-class. Stay on React Router when you want the larger ecosystem and hiring pool, when the team isn't ready for a generated route tree in the build, or when the app's URL state is simple enough that typed search params don't earn their inference cost. For full-stack with RSC, the comparison isn't RR at all — it's Next.js versus TanStack Start, the full-stack framework built on this router.
API / type reference
| API | Purpose |
|---|---|
createFileRoute("/path")({...}) | Define a route; the path literal (plugin-managed) anchors type inference. |
createRootRouteWithContext<T>() | Root route declaring the typed context shape (e.g. { queryClient }). |
createRouter({ routeTree, context, defaultPreload }) | Build the router; Register augmentation makes its types global. |
validateSearch | Parse + type search params (a Zod schema; .catch() vs .default()). |
loaderDeps / loader | Declare search deps that re-trigger the loader / fetch before render. |
beforeLoad | Pre-loader hook for guards; throw redirect({ to, search }). |
Route.useParams() / useSearch() / useLoaderData() | Typed hooks scoped to the route. |
useNavigate({ from }) / getRouteApi("/path") | Typed navigation / typed hooks in code-split components. |
<Link to search params> | Type-checked navigation, including functional search updates. |
Common mistakes
1. Editing the createFileRoute path literal by hand. The plugin owns it and keeps it in sync with the filename. Rename the file; let the generated tree update. A hand-edited literal that drifts from the path breaks inference.
2. Skipping the Register augmentation. Without the declare module block registering your router, none of the global type safety kicks in — Link, useSearch, and friends fall back to loose types. It's a one-time step that's easy to forget.
3. Returning ensureQueryData(...) from the loader. Returning the promise forces TypeScript to infer the loader's return type across the whole route tree, slowing the editor. await it and return nothing so inference stays lazy.
// ❌ forces loader-data inference you never useloader: ({ context: { queryClient }, params }) => queryClient.ensureQueryData(productQuery(params.productId)),// ✅ await + return nothingloader: async ({ context: { queryClient }, params }) => { await queryClient.ensureQueryData(productQuery(params.productId));},4. Forgetting loaderDeps. If the loader reads search params but you don't declare them in loaderDeps, the loader won't re-run when they change — you get stale data on a filter change with no error. Declare every search param the loader reads.
5. .default() where .catch() fits. .default() throws on malformed input, sending users to an error page for a mangled query string. Use .catch() to repair silently unless a bad param genuinely warrants stopping the user.
6. Running two caches. Using both the router's loader cache and TanStack Query for the same data, unintegrated, is the don't-run-two-caches footgun. Put the query client in context and treat the loader as a Query prefetch, not a parallel store.
7. Losing types in code-split components. Route.useSearch() needs to know which route it's in; in a lazily-loaded component that context can be lost. Use getRouteApi("/path") (or pass from) to keep the typed hooks.
8. Treating search params like React Router. Reaching for stringly-typed manual parsing instead of validateSearch throws away the main reason to be here. Model URL state with a schema.
9. Ignoring the TS-inference cost on a huge route tree. On very large apps the search-union check is measurable. It's usually fine (cached), but if editor performance degrades, that's the cause — not a mystery.
10. Migrating a whole React Router app in one pass. This is a real rewrite — file structure, route definitions, and especially the search-param model all change. Migrate a section at a time, or adopt it greenfield.
How this evolved
React Router established client-side routing with a stringly-typed API. Remix introduced the loader/action data model, which React Router adopted as its data mode (article 28 covers that lineage). TanStack Router took the data-router idea and made the entire surface type-safe through a generated route tree — and, crucially, reframed search params as validated typed state, a capability the others never treated as first-class. TanStack Start now extends the router into a full-stack framework (Vite-based), positioning it against Next.js. The arc: routing → data routing (loaders) → type-safe data routing (inference + schematized URL state). React Router remains the incumbent with the deeper ecosystem; TanStack Router is winning greenfield mindshare among TypeScript-first teams precisely on the axes above.
Exercises
1. Put a filter in the URL, typed. Take a list with a client-state useState filter and move it to a validateSearch schema so it's shareable and back-button-correct. Hint: define the Zod schema with .catch() fallbacks, read it with Route.useSearch(), and write it with <Link search={(prev) => ({ ...prev, filter })}> — no useState left.
2. Wire the loader→Query→component chain. Add a queryOptions definition, prefetch it in the loader with ensureQueryData, and read it in the component with useSuspenseQuery. Hint: await the prefetch and return nothing; confirm the component never shows a loading spinner because the cache is warm before render.
3. Guard a route. Protect a /_authenticated layout with beforeLoad, redirecting unauthenticated users to /login and preserving the intended destination. Hint: put the auth service in typed route context so the guard reads context.auth without a global; throw redirect({ to, search: { redirect: location.href } }).
Summary
TanStack Router is React Router's type-safe alternative: a generated route tree plus a one-time Register augmentation make paths, params, and search params fully inferred, so route mistakes are compile errors. Its standout capability is search params as validated, typed state — a materially better model than stringly-typed useSearchParams for any app with real URL state. Its loader integrates cleanly with TanStack Query through a shared queryOptions object (ensureQueryData to prefetch, useSuspenseQuery to read), and beforeLoad plus typed context makes auth guards clean. The costs are a generated route tree, a TypeScript-inference tax that scales with route count, and a smaller ecosystem. Choose it for TS-heavy, URL-state-heavy, Query-first apps; stay on React Router for ecosystem breadth and simpler URL needs; and for full-stack, weigh TanStack Start against Next.js rather than comparing routers.
See also
- Routing with React Router — the incumbent this article is measured against; the shared data-router concepts.
- Data fetching with TanStack Query — the cache the loader prefetches into.
- Suspense — the boundaries behind route code-splitting and pending UI.
- Forms at scale — the Zod usage
validateSearchshares. - Next.js and RSC in practice — the full-stack comparison point for TanStack Start.
- Recipe: URL params out of sync with state (planned) — a failure typed search params largely design out.
References
- TanStack Router — Overview
- TanStack Router — Type Safety
- TanStack Router — Search Params
- TanStack Router — File-Based Routing
- TanStack — Start (full-stack framework)
Demo source
Demo pending — see the roadmap's demo-hosting decision (StackBlitz vs. CodeSandbox vs. local demos/).