mrkeyoor.com_
Thu 06 Aug 07:40 UTC
npmWeb Frontendupdated 06 Aug 2026

@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.

Verdict

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.

API stability4/5Still on the 1.x line with additive minors and codemods or guides for the disruptive changes, but the release stream is fast (1.170.x by August 2026) and the surrounding tooling has been renamed more than once, so pinning and reading release notes is worth the effort.
Docs5/5tanstack.com/router has guides, per-symbol API reference, framework-specific tabs, how-to recipes for auth, SSR, and testing, plus an unusually candid page on TypeScript performance tradeoffs and a comparison table against other routers.
Maintenance5/5Pushed 6 August 2026 with 1.170.20 published a day earlier, backed by the TanStack organisation and sponsors. 268 open issues (524 counting PRs) on a repository that also holds TanStack Start.
Ecosystem4/5About 21.5M weekly downloads and first-party adapters for zod and valibot, an ESLint plugin, devtools, and the Query integration. It is still second to react-router for third-party guides, component libraries, and Stack Overflow answers.

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
Skip it if

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

PackageRegistryPick it when
react-routernpmYou want the incumbent with the biggest ecosystem, an SSR story in the same package, and a gentler learning curve
wouternpmYou need hooks-based routing in a couple of kilobytes and none of the loader, search param, or codegen machinery
@tanstack/react-startnpmYou like this router but also need server rendering, server functions, and deployment handled
nextnpmYou want routing, server rendering, and the build system as one opinionated framework rather than assembled parts