Step-by-step migration from React Router v7 to TanStack Router: route definition conversion, Link/useNavigate API differences, useSearchParams to validateSearch + useSearch, useParams with from, Outlet replacement, loader conversion, code splitting differences.
This is a step-by-step migration checklist. Each check covers one conversion task. Complete them in order.
CRITICAL: If your UI is blank after migration, open the console. Errors like "cannot use useNavigate outside of context" mean React Router imports remain alongside TanStack Router imports. Uninstall
react-router(andreact-router-domif present) to surface them as TypeScript errors.CRITICAL: TanStack Router uses
to+paramsfor navigation, NOT template literal paths. Never interpolate params into thetostring.NOTE: React Router v7 recommends importing from
react-router(notreact-router-dom). Thereact-router-dompackage still exists but just re-exports fromreact-router. Check for imports from both.
git checkout -b migrate-to-tanstack-router
npm install @tanstack/react-router @tanstack/react-router-devtools
npm install -D @tanstack/router-plugin
// 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(),
],
})
mkdir src/routes
React Router: <BrowserRouter> or createBrowserRouter([{ element: <Layout />, children: [...] }])
TanStack Router: src/routes/__root.tsx
// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Outlet />
<TanStackRouterDevtools />
</>
),
})
// src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const rootElement = document.getElementById('root')!
if (!rootElement.innerHTML) {
ReactDOM.createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
createFileRouteReact Router:
// Defined in route config array
{ path: '/posts', element: <Posts />, loader: postsLoader }
TanStack Router:
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts()
return { posts }
},
component: PostsPage,
})
function PostsPage() {
const { posts } = Route.useLoaderData()
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
React Router: /posts/:postId (colon syntax)
TanStack Router: /posts/$postId (dollar syntax)
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
return <article>{post.title}</article>
}
<Link> componentsReact Router:
import { Link } from 'react-router'
;<Link to={`/posts/${postId}`}>View Post</Link>
TanStack Router:
import { Link } from '@tanstack/react-router'
;<Link to="/posts/$postId" params={{ postId }}>
View Post
</Link>
Key differences:
to is a route path pattern, NOT an interpolated string
params is a separate prop with typed values
Active styling: use activeProps={{ className: 'font-bold' }} or data-status="active" attribute for CSS
Convert all useNavigate calls
React Router:
import { useNavigate } from 'react-router'
const navigate = useNavigate()
navigate(`/posts/${postId}`)
TanStack Router:
import { useNavigate } from '@tanstack/react-router'
const navigate = useNavigate()
navigate({ to: '/posts/$postId', params: { postId } })
useSearchParams with validateSearch + useSearchReact Router:
import { useSearchParams } from 'react-router'
function Posts() {
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page')) || 1
const goToPage = (p: number) => setSearchParams({ page: String(p) })
}
TanStack Router:
// In the route definition:
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
page: z.number().default(1).catch(1),
}),
component: Posts,
})
// In the component:
import { useNavigate } from '@tanstack/react-router'
function Posts() {
const { page } = Route.useSearch()
const navigate = useNavigate({ from: '/posts' })
const goToPage = (p: number) => {
navigate({ search: (prev) => ({ ...prev, page: p }) })
}
}
Key differences:
useSearch() returns typed objects, not URLSearchParamsuseParams with from propertyReact Router:
import { useParams } from 'react-router'
const { postId } = useParams()
TanStack Router:
import { useParams } from '@tanstack/react-router'
const { postId } = useParams({ from: '/posts/$postId' })
Or from within the route component:
const { postId } = Route.useParams()
useLocation — Common PitfalluseLocation with specific hooksReact Router's useLocation is heavily used, and TanStack Router has a hook with the same name — but they are NOT equivalent. TanStack Router's useLocation() returns the router's current location, which during pending navigations may differ from what's currently rendered. Most React Router useLocation usage should be replaced with more specific hooks. See #3110.
Replace based on what you actually need:
// React Router
import { useLocation } from 'react-router'
const location = useLocation()
// ❌ DON'T just swap to TanStack Router's useLocation — it's the "live" URL
import { useLocation } from '@tanstack/react-router'
// ✅ DO use the specific hook for what you need:
import {
useMatch,
useMatches,
useParams,
useSearch,
} from '@tanstack/react-router'
// Current route match (replaces most useLocation().pathname usage)
const match = useMatch({ from: '/posts/$postId' })
// All active matches (replaces useLocation for breadcrumbs/analytics)
const matches = useMatches()
// Path params (replaces useLocation + manual parsing)
const { postId } = useParams({ from: '/posts/$postId' })
// Search params (replaces useLocation().search parsing)
const { page } = useSearch({ from: '/posts' })
Outlet with TanStack Router OutletThe API is identical — just change the import:
// Before
import { Outlet } from 'react-router'
// After
import { Outlet } from '@tanstack/react-router'
React Router (v7):
export async function loader({ params }) {
const post = await fetchPost(params.postId)
return { post }
}
export default function Post() {
const { post } = useLoaderData()
return <div>{post.title}</div>
}
TanStack Router:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
component: Post,
})
function Post() {
const { post } = Route.useLoaderData()
return <div>{post.title}</div>
}
Key differences:
useLoaderData() is called via Route.useLoaderData() (or useLoaderData({ from }))json() wrapper needed — return plain objectsautoCodeSplitting: true in the plugin config, this is automatic. For manual splitting, use .lazy.tsx files:// src/routes/lazy-page.lazy.tsx
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/lazy-page')({
component: () => <div>Lazy loaded</div>,
})
npm uninstall react-router react-router-dom
grep -r "from 'react-router" src/ # find stale imports
npx tsc --noEmit # verify clean build
Both libraries export Link, useNavigate, Outlet, etc. Leftover React Router imports cause "cannot use useNavigate outside of context" errors because the wrong context provider is used.
// WRONG — mixed imports
import { Link } from '@tanstack/react-router'
import { useNavigate } from 'react-router' // <- still React Router!
// CORRECT — all from TanStack Router
import { Link, useNavigate } from '@tanstack/react-router'
Fix: Uninstall react-router/react-router-dom completely. TypeScript will flag every stale import.
useSearchParams pattern// WRONG — React Router pattern, returns URLSearchParams
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page'))
// CORRECT — TanStack Router pattern, returns typed object
// Route definition:
validateSearch:
z.object({
page: z.number().default(1).catch(1),
}),
// Component:
const { page } = Route.useSearch()
// page is already typed as number — no casting needed
to string// WRONG — React Router habit
<Link to={`/posts/${postId}`}>Post</Link>
// CORRECT — TanStack Router: path pattern + params prop
<Link to="/posts/$postId" params={{ postId }}>Post</Link>
:param syntax instead of $paramReact Router: /posts/:postId
TanStack Router: /posts/$postId
File naming also uses $: src/routes/posts/$postId.tsx
| React Router v7 | TanStack Router |
|---|---|
<BrowserRouter> | <RouterProvider router={router} /> |
<Routes> / <Route> | File-based: src/routes/*.tsx |
<Link to="/path"> | <Link to="/path"> |
<Link to={/posts/${id}}> | <Link to="/posts/$postId" params={{ postId: id }}> |
useNavigate()('/path') | navigate({ to: '/path' }) |
useParams() | useParams({ from: '/route/$param' }) |
useSearchParams() | validateSearch + useSearch({ from }) |
useLoaderData() | Route.useLoaderData() |
useLocation() | useMatch, useMatches, useParams, useSearch |
<Outlet /> | <Outlet /> |
loader({ params }) | loader: ({ params }) => ... (route option) |
action({ request }) | Use mutations / form libraries |
lazy(() => import(...)) | autoCodeSplitting or .lazy.tsx files |
:paramName | $paramName |
* (splat) | $ (splat, accessed via _splat) |