How to Migrate From React Router v6 to TanStack Router v1
- Install
@tanstack/react-router,@tanstack/router-plugin,@tanstack/router-devtools, and@tanstack/zod-adapter. - Configure the
TanStackRouterVite()plugin invite.config.tsbefore the React plugin. - Create route files in
src/routes/using file-based naming conventions (__root.tsx,index.tsx,posts.$postId.tsx). - Register the router type globally via
createRouter()and thedeclare moduleblock insrc/router.ts. - Replace React Router's
<RouterProvider>or<BrowserRouter>with TanStack Router's<RouterProvider>. - Update all
<Link>,useNavigate(),useParams(), anduseSearchParams()calls to TanStack Router's type-safe equivalents. - Migrate loaders to route-level
loaderfunctions and swapuseLoaderData()forRoute.useLoaderData(). - Remove
react-router-domfrom dependencies and runtsc --noEmitto verify full type coverage.
React Router has been the default routing library in React applications for years, but its type safety story remains incomplete. TanStack Router v1, which reached stable release with file-based routing, fully infers TypeScript types across four specific surfaces: route params, search params, loaders, and navigation.
The steps below migrate a React Router v6 app to TanStack Router v1 without a full rewrite. The target reader is an intermediate React and TypeScript developer currently using React Router v6 who wants a clear, actionable migration path.
Table of Contents
- Key Differences Between React Router and TanStack Router
- Prerequisites and Project Setup
- Step 1: Converting Route Definitions to File-Based Routes
- Step 2: Setting Up the Router Instance and Providers
- Step 3: Migrating Navigation and Links
- Step 4: Typed Route Params and Search Params
- Step 5: Data Loading and Pending States
- Step 6: Error Handling and Not-Found Routes
- Migration Checklist and Common Pitfalls
- Is the Migration Worth It?
Key Differences Between React Router and TanStack Router
Before touching any code, understanding the structural differences between these two routers clarifies why the migration requires specific changes at each layer.
Route Definition Model
React Router v6 defines routes either declaratively with JSX <Route> elements or programmatically via createBrowserRouter with route objects. Both approaches require manual assembly and produce no compile-time guarantees about route path validity.
TanStack Router uses a file-based convention. The Vite plugin discovers and compiles route files from src/routes/ into a generated route tree (routeTree.gen.ts). A code-based approach also exists, but the file-based system is the primary recommended path. The generated route tree is what enables full type inference across the entire application.
Type Safety
React Router's useParams() returns Readonly<Record<string, string | undefined>>, meaning every parameter value may be undefined at the type level regardless of the route definition. There are no typed search params and no typed navigation. Developers typically bolt on manual type assertions or wrapper hooks that the compiler cannot verify against the actual route configuration.
TanStack Router infers types for params, search params, loader data, and navigate() calls directly from route definitions. Passing an invalid path to <Link to="..."> or useNavigate() produces a compile-time error. No generics need to be supplied manually once the router type is registered via the declare module block (described in Step 2); the types flow from the route tree.
Passing an invalid path to
<Link to="...">oruseNavigate()produces a compile-time error.
Data Loading
React Router v6.4+ introduced loader functions on route objects (Remix-style) that run before rendering. TanStack Router also supports loader on route definitions, but adds typed context for dependency injection (such as passing a queryClient) and optional deep integration with TanStack Query via a dedicated plugin.
The following table summarizes the API mapping for common operations:
| Operation | React Router v6 | TanStack Router v1 |
|---|---|---|
| Defining routes | createBrowserRouter([...]) or <Route> JSX |
File-based convention + generated routeTree |
| Reading route params | useParams() (returns Readonly<Record<string, string | undefined>>) |
useParams() (fully inferred from route definition) |
| Reading search params | useSearchParams() (untyped URLSearchParams) |
useSearch() (validated and typed via validateSearch) |
| Navigation | useNavigate() (accepts any string) |
useNavigate() (type-checked against route tree) |
| Data loading | loader in route config + useLoaderData() |
loader in route file + Route.useLoaderData() |
| Error handling | errorElement on route config |
errorComponent on route definition |
Prerequisites and Project Setup
What You'll Need
The migration requires Node.js 18+, TypeScript 5+ (required for const type parameter inference used by createFileRoute; earlier TypeScript versions will lose route-level type inference), and an existing React Router v6 application (or a starter repo structured similarly). This article assumes you know React Router v6 concepts like outlets, nested routes, and loaders.
Installing TanStack Router
Install the core packages, devtools, and the Zod adapter for search param validation:
npm install @tanstack/react-router @tanstack/router-devtools @tanstack/zod-adapter
npm install -D @tanstack/router-plugin
Note: The versions used in this article should be pinned to the versions you test against (e.g.,
@tanstack/react-router@1.x.x). TanStack Router has had breaking changes between minor versions, particularly around thevalidateSearchAPI. Runnpm ls @tanstack/react-routerto confirm your installed version.
Then register the Vite plugin so the file-based route tree is automatically generated. The relevant changes to vite.config.ts:
// 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(),
react(),
],
})
The TanStackRouterVite() plugin must appear before the React plugin so that route files are transformed before the React JSX transform runs. It watches the src/routes/ directory (by default; pass { routesDirectory: './app/routes' } to customize) and regenerates routeTree.gen.ts on file changes.
Step 1: Converting Route Definitions to File-Based Routes
Understanding the File-Based Routing Convention
TanStack Router maps files in src/routes/ to route segments using specific naming conventions:
__root.tsxdefines the root layout (equivalent to React Router's root route with an<Outlet />).index.tsxmaps to the/path.about.tsxmaps to/about.posts.$postId.tsxmaps to/posts/:postId, where$postIddenotes a dynamic segment._layout.tsx(prefixed with underscore) creates a pathless layout route that wraps child routes without adding a URL segment.
When the dev server starts (or on file save), the Vite plugin generates src/routeTree.gen.ts. This file exports the full route tree with all type information. It should be committed to version control or regenerated in CI. Either approach is valid, but committing requires a CI diff-check step to detect stale generated files (e.g., run the generator then git diff --exit-code); excluding from VCS requires the generator to run before tsc in CI. The file must exist for type checking to pass.
Migrating Your First Routes
Consider a typical React Router v6 configuration with a root layout, an index page, an about page, and a dynamic post detail page:
// Before: React Router v6 — src/router.tsx
import { createBrowserRouter } from 'react-router-dom'
import RootLayout from './layouts/RootLayout'
import HomePage from './pages/HomePage'
import AboutPage from './pages/AboutPage'
import PostPage from './pages/PostPage'
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'about', element: <AboutPage /> },
{ path: 'posts/:postId', element: <PostPage /> },
],
},
])
The equivalent TanStack Router file structure:
src/routes/
├── __root.tsx
├── index.tsx
├── about.tsx
└── posts.$postId.tsx
Each file uses createFileRoute to define its route:
// src/routes/__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router'
import React from 'react'
// Lazy-loaded: excluded from production bundle by Vite tree-shaking
const TanStackRouterDevtools =
process.env.NODE_ENV === 'production'
? () => null
: React.lazy(() =>
import('@tanstack/router-devtools').then((m) => ({
default: m.TanStackRouterDevtools,
})),
)
export const Route = createRootRoute({
component: () => (
<div>
<nav>
<Link to="/" activeProps={{ className: 'active' }}>Home</Link>{' '}
<Link to="/about" activeProps={{ className: 'active' }}>About</Link>
</nav>
<Outlet />
<React.Suspense fallback={null}>
<TanStackRouterDevtools />
</React.Suspense>
</div>
),
})
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: () => <h1>Home Page</h1>,
})
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: () => <h1>About Page</h1>,
})
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: () => <h1>Post Detail</h1>,
})
The string passed to createFileRoute must match the file path exactly. The Vite plugin validates this and will flag mismatches.
Step 2: Setting Up the Router Instance and Providers
Creating the Router
Import the generated route tree and pass it to createRouter(). Then register the router's type globally so that hooks like useNavigate() and components like <Link> infer the correct types without manual generics:
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
// This block MUST be included and this file MUST be part of your TypeScript
// compilation (check tsconfig.json "include" paths). Without it, TanStack
// Router hooks and components fall back to loose, untyped signatures.
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
The declare module block is not optional. Without it, TanStack Router's hooks and components fall back to loose types, which removes compile-time route checking entirely. useNavigate() will accept any string instead of restricting to to valid route paths, and useParams() will return a generic record instead of route-specific typed params.
Replacing <RouterProvider>
Swap React Router's provider in the application entry point:
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Root element not found')
ReactDOM.createRoot(rootElement!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)
This replaces both <BrowserRouter> (if using the declarative approach) and React Router's <RouterProvider>.
Step 3: Migrating Navigation and Links
Replacing <Link> and useNavigate()
TanStack Router's <Link> component requires a to prop that must be a valid route path from the generated route tree. TypeScript errors if you pass a string that doesn't match a defined route.
Similarly, useNavigate() returns a function whose to parameter is type-checked. Typos in route paths, or references to routes that have been renamed or deleted, surface during compilation rather than at runtime.
Handling Active Link Styling
React Router's <NavLink> accepts a className callback that receives an isActive boolean. TanStack Router's <Link> uses activeProps and inactiveProps instead:
// Before: React Router
import { NavLink } from 'react-router-dom'
function Navbar() {
return (
<nav>
<NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
Home
</NavLink>
<NavLink to="/about" className={({ isActive }) => isActive ? 'active' : ''}>
About
</NavLink>
</nav>
)
}
// After: TanStack Router
import { Link } from '@tanstack/react-router'
function Navbar() {
return (
<nav>
<Link to="/" activeProps={{ className: 'active' }}>
Home
</Link>
<Link to="/about" activeProps={{ className: 'active' }}>
About
</Link>
</nav>
)
}
The activeProps and inactiveProps approach merges props rather than replacing them, which avoids the common React Router pitfall of accidentally overwriting non-active styles.
Step 4: Typed Route Params and Search Params
Accessing Route Params with Full Type Safety
In React Router, useParams() returns Readonly<Record<string, string | undefined>>, meaning every parameter access requires a null check or type assertion. TanStack Router infers param types directly from the route definition. Inside posts.$postId.tsx, calling useParams returns an object where postId is typed as string, not string | undefined.
No generic argument is needed. The types are inferred from the route file's path string.
Validating and Typing Search Params
This is where TanStack Router diverges most sharply from React Router. Define a validateSearch function on the route using Zod (via the @tanstack/zod-adapter wrapper) or a plain validation function to declare and enforce the shape of search parameters:
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { zodSearchValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const postSearchSchema = z.object({
page: z.number().default(1),
})
export { postSearchSchema }
export const Route = createFileRoute('/posts/$postId')({
validateSearch: zodSearchValidator(postSearchSchema),
component: PostDetail,
})
function PostDetail() {
const { postId } = Route.useParams()
const { page } = Route.useSearch()
return (
<div>
<h1>Post {postId}</h1>
<p>Page: {page}</p>
</div>
)
}
Important: TanStack Router v1's
validateSearchexpects a validator conforming to its internalSearchSchemaValidatorinterface, not a raw Zod schema. Wrapping withzodSearchValidator()from@tanstack/zod-adapterbridges this gap. Passing a raw Zod schema directly will either silently fail or throw at runtime.
When linking to this route, the search prop on <Link> is type-checked against the schema:
<Link
to="/posts/$postId"
params={{ postId: '42' }}
search={{ page: 2 }}
>
View Post 42, Page 2
</Link>
Passing search={{ page: 'two' }} would produce a compile-time error because page expects a number. React Router's useSearchParams() returns raw URLSearchParams with no validation or typing whatsoever, so this entire category of bug simply cannot be caught statically with React Router.
Step 5: Data Loading and Pending States
Migrating React Router Loaders
TanStack Router loaders run before rendering, the same timing model as React Router v6.4+. The key difference: loader return types are fully inferred, and the context parameter supports dependency injection. You can pass a queryClient through router context and use it in every loader without importing it directly:
// Before: React Router
import { useLoaderData } from 'react-router-dom'
import type { LoaderFunctionArgs } from 'react-router-dom'
interface Post {
id: string
title: string
body: string
}
export async function loader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/api/posts/${params.postId}`)
if (!res.ok) {
const body = await res.text()
throw new Error(`HTTP ${res.status}: ${body}`)
}
return res.json() as Promise<Post>
}
function PostPage() {
const post = useLoaderData() as Post // manual cast required
return <h1>{post.title}</h1>
}
// After: TanStack Router — src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const PostSchema = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
})
type Post = z.infer<typeof PostSchema>
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, abortController }) => {
const res = await fetch(`/api/posts/${params.postId}`, {
signal: abortController.signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`HTTP ${res.status}: ${body}`)
}
// Runtime-validated: safe parsed type, not a blind assertion
return PostSchema.parse(await res.json())
},
component: PostDetail,
pendingComponent: () => <div>Loading post…</div>,
})
function PostDetail() {
// Inferred type is z.infer<typeof PostSchema> — fully safe
const post = Route.useLoaderData()
return <h1>{post.title}</h1>
}
Accessing Loader Data
Route.useLoaderData() replaces React Router's useLoaderData(). It infers its return type from whatever the loader function returns, so you don't need generic parameters or type assertions at the component level. In the example above, the loader validates the API response at runtime with a Zod schema (PostSchema.parse()), so the inferred type matches the actual data. For loaders that skip runtime validation, Route.useLoaderData() infers whatever type the loader declares, including unsafe casts. For true end-to-end safety, validate the API response with a schema.
Showing Pending UI
React Router requires checking useNavigation().state === 'loading' and conditionally rendering loading indicators. TanStack Router replaces this with pendingComponent on the route definition, which renders automatically once loader execution exceeds pendingMs milliseconds (default: 1000 ms; verify against the version you're running, as this default has changed between releases). Fast loaders that complete within this threshold will not trigger pendingComponent. Set pendingMs: 0 on the route if you want the pending UI to appear immediately. This co-locates the loading state with the route rather than scattering navigation state checks through components.
Step 6: Error Handling and Not-Found Routes
Route-Level Error Boundaries
TanStack Router uses errorComponent on individual route definitions, replacing React Router's errorElement. The error component receives { error } as a prop, where error is typed as unknown. This means you must narrow the type before accessing properties like .message:
errorComponent: ({ error }) => (
<div>Something went wrong: {error instanceof Error ? error.message : String(error)}</div>
)
Catch-All / Not Found
The root route supports notFoundComponent, which renders when no route matches the current URL. Individual nested routes can also define notFoundComponent for section-level not-found handling.
// src/routes/__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router'
import React from 'react'
// Lazy-loaded: excluded from production bundle by Vite tree-shaking
const TanStackRouterDevtools =
process.env.NODE_ENV === 'production'
? () => null
: React.lazy(() =>
import('@tanstack/router-devtools').then((m) => ({
default: m.TanStackRouterDevtools,
})),
)
export const Route = createRootRoute({
component: () => (
<div>
<nav>
<Link to="/" activeProps={{ className: 'active' }}>Home</Link>{' '}
<Link to="/about" activeProps={{ className: 'active' }}>About</Link>
</nav>
<Outlet />
<React.Suspense fallback={null}>
<TanStackRouterDevtools />
</React.Suspense>
</div>
),
errorComponent: ({ error }) => (
<div>Something went wrong: {error instanceof Error ? error.message : String(error)}</div>
),
notFoundComponent: () => <div>404 — Page not found</div>,
})
Migration Checklist and Common Pitfalls
Complete Migration Checklist
Installation
- ☐ Install
@tanstack/react-router,@tanstack/router-devtools,@tanstack/zod-adapter, and@tanstack/router-plugin. - ☐ Configure the
TanStackRouterVite()plugin invite.config.ts(before the React plugin).
Route Files
- ☐ Create
src/routes/__root.tsxwith the root layout and<Outlet />. - ☐ Convert each route to a file in
src/routes/following naming conventions. - ☐ Start the dev server (
npm run dev) to trigger generation ofrouteTree.gen.ts. This file must exist before type checking will pass.
Router Configuration
- ☐ Create the router instance in
src/router.tswith the generatedrouteTree. - ☐ Add the
declare moduletype registration block for the router. Ensurerouter.tsis included in yourtsconfig.jsonincludepaths so the ambient module augmentation is active. - ☐ Replace
<BrowserRouter>or React Router's<RouterProvider>in the entry point.
Navigation and Hooks
- ☐ Replace all
<Link>and<NavLink>imports with TanStack Router's<Link>. - ☐ Replace
useNavigate()calls (update path arguments to typedtoprop). - ☐ Replace
useParams()calls and remove manual type assertions. - ☐ Add
validateSearch(usingzodSearchValidator()from@tanstack/zod-adapter) to routes that use query strings.
Loaders and Error Handling
- ☐ Migrate
loaderfunctions and replaceuseLoaderData()withRoute.useLoaderData(). - ☐ Replace
errorElementwitherrorComponenton relevant routes. - ☐ Add
notFoundComponentto the root route (and optionally to nested routes for section-level 404s).
Cleanup and Verification
- ☐ Remove
react-router-domfrom dependencies. - ☐ Ensure
routeTree.gen.tshas been generated (runnpm run devorvite buildfirst, or run the router CLI generator if configured). Then runtsc --noEmitto verify full type coverage across all routes.
Common Pitfalls
Cannot find module './routeTree.gen' during type checking. The Vite plugin must run before tsc can succeed. If the plugin is misconfigured or hasn't run yet, routeTree.gen.ts won't exist. Check the plugin order in vite.config.ts and confirm the file appears in src/.
Hardcoded path strings bypass type checking. Always use the route path constants inferred by the generated route tree rather than raw strings passed to navigation functions.
If you omit the declare module '@tanstack/react-router' block, hooks and components silently default to untyped signatures. Everything compiles, nothing is checked. This is the most common source of "it worked but I have no type errors" confusion.
Raw Zod schemas passed to validateSearch won't work. TanStack Router v1 requires wrapping Zod schemas with zodSearchValidator() from @tanstack/zod-adapter. A raw schema will either silently bypass validation or throw at runtime.
pendingComponent never appears for fast loaders. The pending UI only renders after the pendingMs threshold elapses (default: 1000 ms). If your loader resolves in 200 ms, you'll never see it. Set pendingMs: 0 on the route if you want the pending UI to show immediately.
Is the Migration Worth It?
The migration from React Router v6 to TanStack Router v1 delivers type safety across four surfaces that React Router leaves untyped: route params, search params, navigation targets, and loader data. Every one of these represents a category of runtime bug that becomes a compile-time error after migration. Loader data type safety extends only as far as the loader's declared return type; runtime validation of external data (e.g., API responses) still requires a schema library like Zod for true end-to-end safety.
Every one of these represents a category of runtime bug that becomes a compile-time error after migration.
The effort scales with your route count and the number of call sites that touch routing APIs. Each route requires one new file. Every <Link>, useNavigate(), useParams(), and useLoaderData() call site must be updated. For a 10-route app with 30 navigation call sites, expect a focused afternoon. For 50+ routes with heavy search param usage, budget several days. The payoff compounds: renaming a route parameter or changing a search param schema propagates type errors to every consumer at compile time, so a renamed param breaks every consumer before code review instead of in production.
For further reading, the TanStack Router documentation at tanstack.com/router covers advanced patterns including authenticated routes, route masking, and SSR integration. TanStack Start, the full-stack framework built on TanStack Router, extends these capabilities with server functions and full-stack type safety for teams ready to go further.

