React Router to TanStack Router Migration
Migrate React applications from React Router to TanStack Router with file-based routing. This skill provides a structured approach for both incremental and clean migrations.
Critical Rules
ALWAYS:
-
Use file-based routing with routes in src/routes/ directory
-
Use from parameter in all hooks for type safety (useParams({ from: '/path' }) )
-
Validate search params with Zod schemas using @tanstack/zod-adapter
-
Configure build tool plugin before creating routes
-
Register router type for full TypeScript inference
-
Use fallback() wrapper for optional search params
NEVER:
-
Edit routeTree.gen.ts (auto-generated file)
-
Use React Router hooks in new code during migration
-
Forget the from parameter (loses type safety)
-
Use string-only validation for search params
-
Skip the build plugin configuration
Dependencies
Core dependencies
bun add @tanstack/react-router @tanstack/zod-adapter
Build plugin (choose one based on your bundler)
bun add -d @tanstack/router-plugin
Optional integrations
bun add nuqs # URL state management bun add @sentry/react # Error tracking with router integration
Migration Phases
Phase 1: Assessment
Audit existing React Router usage:
Find all React Router imports
grep -r "from 'react-router" src/ --include=".tsx" --include=".ts" grep -r 'from "react-router' src/ --include=".tsx" --include=".ts"
Find hook usages
grep -r "useParams|useSearchParams|useNavigate|useLocation|useMatch" src/
Document:
-
React Router version (v5 or v6)
-
Number of routes
-
useParams usage count
-
useSearchParams usage count
-
useNavigate usage count
-
Custom Link components
-
Route guards/protected routes
-
Existing route structure
Phase 2: Setup
- Configure Build Tool
See references/build-configuration.md for full configs.
Rspack/Rsbuild:
// rsbuild.config.ts import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';
export default { tools: { rspack: (config) => { config.plugins?.push( TanStackRouterRspack({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/routes', generatedRouteTree: './src/routeTree.gen.ts', quoteStyle: 'single', semicolons: true, }) ); // Prevent rebuild loop config.watchOptions = { ignored: ['**/routeTree.gen.ts'] }; return config; }, }, };
Vite:
// vite.config.ts import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({ plugins: [ TanStackRouterVite({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/routes', generatedRouteTree: './src/routeTree.gen.ts', }), react(), ], });
- Configure Linter
// biome.jsonc or eslint config { "files": { "ignore": ["/routeTree.gen.ts"] }, "overrides": [ { "include": ["/routes/**/*"], "linter": { "rules": { "style": { "useFilenamingConvention": "off" // Allow $param.tsx naming } } } } ] }
- Create Routes Directory
mkdir -p src/routes
Phase 3: Router Creation
Create Router Instance:
// src/app.tsx import { createRouter, RouterProvider } from '@tanstack/react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { routeTree } from './routeTree.gen'; import { NotFoundPage } from './components/misc/not-found-page';
const queryClient = new QueryClient();
const router = createRouter({ routeTree, context: { basePath: getBasePath(), queryClient, }, basepath: getBasePath(), trailingSlash: 'never', defaultNotFoundComponent: NotFoundPage, });
// Register router type for full TypeScript inference declare module '@tanstack/react-router' { interface Register { router: typeof router; }
// Extend HistoryState for typed navigation state interface HistoryState { // Add your custom state properties here returnUrl?: string; documentId?: string; documentName?: string; } }
export function App() { return ( <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider> ); }
Define Router Context Type:
// src/routes/__root.tsx import type { QueryClient } from '@tanstack/react-query';
export type RouterContext = { basePath: string; queryClient: QueryClient; };
Phase 4: Route Migration
Create Root Layout:
// src/routes/__root.tsx import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import type { QueryClient } from '@tanstack/react-query'; import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
export type RouterContext = { basePath: string; queryClient: QueryClient; };
export const Route = createRootRouteWithContext<RouterContext>()({ component: RootLayout, });
function RootLayout() { return ( <> <NuqsAdapter> <ErrorBoundary> <AppLayout> <Outlet /> </AppLayout> </ErrorBoundary> </NuqsAdapter> {process.env.NODE_ENV === 'development' && ( <TanStackRouterDevtools position="bottom-right" /> )} </> ); }
File-Based Route Structure:
src/routes/ ├── __root.tsx # Root layout ├── index.tsx # / (root redirect) ├── overview/ │ └── index.tsx # /overview ├── topics/ │ ├── index.tsx # /topics │ └── $topicName/ │ ├── index.tsx # /topics/:topicName │ └── edit.tsx # /topics/:topicName/edit ├── security/ │ ├── index.tsx # /security (redirect) │ ├── acls/ │ │ ├── index.tsx # /security/acls │ │ ├── create.tsx # /security/acls/create │ │ └── $aclName/ │ │ └── details.tsx # /security/acls/:aclName/details
See references/route-templates.md for complete templates.
Phase 5: Hook Migration
React Router TanStack Router
useParams()
useParams({ from: '/path/$param' })
useSearchParams()
routeApi.useSearch() with Zod validation
useNavigate()
useNavigate({ from: '/path' })
useLocation()
useLocation() (same API)
<Link to="/path">
<Link to="/path"> (type-safe)
<Navigate to="/path" />
<Navigate to="/path" />
See references/migration-patterns.md for detailed before/after examples.
Navigation State:
Pass typed state between routes using HistoryState :
// Navigating with state const navigate = useNavigate(); navigate({ to: '/documents/$documentId', params: { documentId }, state: { returnUrl: location.pathname, documentName: 'My Document', }, });
// Reading state in destination component import { useLocation } from '@tanstack/react-router';
function DocumentPage() { const location = useLocation(); const { returnUrl, documentName } = location.state; // Use state values... }
useParams Migration:
// Before (React Router) import { useParams } from 'react-router-dom'; const { id } = useParams<{ id: string }>();
// After (TanStack Router) import { useParams } from '@tanstack/react-router'; const { id } = useParams({ from: '/items/$id' });
useSearch with Zod Validation:
// In route file import { fallback, zodValidator } from '@tanstack/zod-adapter'; import { z } from 'zod';
const searchSchema = z.object({ tab: fallback(z.string().optional(), undefined), page: fallback(z.number().optional(), 1), q: fallback(z.string().optional(), undefined), });
export const Route = createFileRoute('/items/')({ validateSearch: zodValidator(searchSchema), component: ItemsPage, });
// In component import { getRouteApi, useNavigate } from '@tanstack/react-router';
const routeApi = getRouteApi('/items/');
function ItemsPage() { const { tab, page, q } = routeApi.useSearch(); const navigate = useNavigate({ from: '/items/' });
const handleTabChange = (newTab: string) => { navigate({ search: (prev) => ({ ...prev, tab: newTab }) }); }; }
Phase 6: Testing
Create Test Utilities:
// src/test-utils.tsx import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, type RenderOptions } from '@testing-library/react'; import { routeTree } from './routeTree.gen'; import type { RouterContext } from './routes/__root';
interface RenderWithFileRoutesOptions extends Omit<RenderOptions, 'wrapper'> { initialLocation?: string; routerContext?: Partial<RouterContext>; }
export function renderWithFileRoutes( ui: React.ReactElement | null = null, { initialLocation = '/', routerContext = {}, ...renderOptions }: RenderWithFileRoutesOptions = {} ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, });
const router = createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialLocation] }), context: { basePath: '', queryClient, ...routerContext }, });
function Wrapper({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <RouterProvider router={router}>{children}</RouterProvider> </QueryClientProvider> ); }
return { ...render(ui ?? <div />, { wrapper: Wrapper, ...renderOptions }), router, }; }
export async function renderRoute(location: string, options?: RenderWithFileRoutesOptions) { const result = renderWithFileRoutes(null, { initialLocation: location, ...options }); await result.router.load(); return result; }
Configure Vitest:
// vitest.config.integration.mts import { tanstackRouter } from '@tanstack/router-plugin/vite';
export default defineConfig({ plugins: [ tanstackRouter({ target: 'react', routesDirectory: './src/routes', generatedRouteTree: './src/routeTree.gen.ts', }), react(), ], test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], }, });
Phase 7: Integrations
Sentry Integration:
// src/app.tsx import * as Sentry from '@sentry/react';
Sentry.init({ dsn: process.env.SENTRY_DSN, integrations: [ Sentry.tanstackRouterBrowserTracingIntegration(router), ], tracesSampleRate: 1.0, });
nuqs Integration:
// src/routes/__root.tsx import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
function RootLayout() { return ( <NuqsAdapter> <Outlet /> </NuqsAdapter> ); }
Incremental Migration (Legacy Compatibility):
See references/incremental-migration.md for patterns to run both routers together during migration.
Quick Reference
Route File Naming
Pattern File URL
Index route topics/index.tsx
/topics
Dynamic param topics/$topicName.tsx
/topics/:topicName
Nested dynamic topics/$topicName/edit.tsx
/topics/:topicName/edit
Pathless layout _layout.tsx
(no URL segment)
Catch-all $.tsx
/*
Common Zod Patterns
import { fallback, zodValidator } from '@tanstack/zod-adapter'; import { z } from 'zod';
const searchSchema = z.object({ // Optional string with undefined default tab: fallback(z.string().optional(), undefined),
// Optional number with default value page: fallback(z.number().optional(), 1),
// Required string id: z.string(),
// Enum with default sort: fallback(z.enum(['asc', 'desc']).optional(), 'asc'),
// Boolean expanded: fallback(z.boolean().optional(), false), });
Trailing Slash in from Parameter
The from parameter must exactly match the route path as defined:
// Index routes (files named index.tsx) include trailing slash: useParams({ from: '/topics/$topicName/' }) // Route: topics/$topicName/index.tsx
// Non-index routes do NOT include trailing slash: useParams({ from: '/topics/$topicName/edit' }) // Route: topics/$topicName/edit.tsx
Type-Safe Navigation
// With params <Link to="/topics/$topicName" params={{ topicName: 'my-topic' }}> View Topic </Link>
// With search params <Link to="/topics" search={{ page: 2, sort: 'desc' }}> Page 2 </Link>
// Programmatic navigation const navigate = useNavigate({ from: '/topics/$topicName' }); navigate({ to: '/topics/$topicName/edit', params: { topicName }, search: { tab: 'settings' }, });
Checklist
Pre-Migration
-
Dependencies installed (@tanstack/react-router , @tanstack/router-plugin , @tanstack/zod-adapter )
-
Build tool plugin configured
-
Linter configured to allow $param.tsx naming
-
src/routes/ directory created
Route Migration
-
__root.tsx created with providers and layout
-
index.tsx created for root redirect
-
All routes migrated to file-based structure
-
Search params validated with Zod schemas
-
staticData added for titles/icons
Hook Migration
-
All useParams calls updated with from parameter
-
All useSearchParams replaced with routeApi.useSearch()
-
All useNavigate calls updated with from parameter
-
All Link components verified working
Testing
-
renderWithFileRoutes utility created
-
Vitest configured with TanStack Router plugin
-
Existing tests updated to use new utilities
Integrations
-
Sentry integration configured (if used)
-
nuqs adapter wrapped in root layout (if used)
Cleanup (after full migration)
-
React Router dependencies removed
-
Legacy route definitions deleted
-
BrowserRouter wrapper removed
-
RouterSync component removed
Common Pitfalls
-
Missing from parameter - Always specify from in hooks for type safety
-
Forgetting fallback() wrapper - Optional search params need fallback(z.string().optional(), undefined)
-
Trailing slash inconsistency - Configure trailingSlash: 'never' and be consistent
-
Editing routeTree.gen.ts - Never edit; it's auto-generated on file changes
-
Missing build plugin - Routes won't generate without the bundler plugin
-
Async navigation warnings - navigate() returns Promise; use void navigate() or await it
-
Using <Navigate> for section redirects - Use beforeLoad with throw redirect() instead to prevent navigation loops in embedded mode: beforeLoad: () => { throw redirect({ to: '/section/$tab', params: { tab: 'default' }, replace: true }); }
-
Trailing slash in from parameter for index routes - Index routes (files named index.tsx ) require trailing slash in from : // Index route: /topics/$topicName/index.tsx useParams({ from: '/topics/$topicName/' }) // ✅ Correct (trailing slash) useParams({ from: '/topics/$topicName' }) // ❌ Wrong
-
Missing HistoryState extension - Extend HistoryState interface for typed navigation state (see Phase 3)
Documentation
-
TanStack Router Docs
-
File-Based Routing
-
Testing File-Based Routes
-
Sentry Integration
-
nuqs Adapters