"Stop fighting your router. TanStack Router gives you 100% type-safe navigation, file-based routing, built-in data loading, and URL state management that actually works. Here's everything you need to know."
Happy Coding! — Ahmed Fahmy
You've been there. You type a route path, hit save, and everything looks fine. Then at runtime—404. Or worse, you navigate to /users/profle instead of /users/profile and spend 20 minutes wondering why the page is blank.
React Router doesn't catch that. It can't. It doesn't know your routes at compile time.
TanStack Router does.
// React Router — no error, wrong URL, good luck debugging
navigate("/users/profle/123");
// TanStack Router — TypeScript error immediately
navigate({ to: "/users/$userId", params: { userId: "123" } });
// ↑ autocomplete works here
// ↑ wrong path = compile error
That's the difference. And it goes much deeper than just paths.
TanStack Router is a fully type-safe client-side router for React. It's not just a router—it's a complete routing solution with:
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin @tanstack/router-devtools
// 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({ target: "react", autoCodeSplitting: true }),
react(),
],
});
The plugin watches your src/routes/ folder and auto-generates routeTree.gen.ts. You never touch that file.
// src/main.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({
routeTree,
defaultPreload: 'intent', // preload on hover
})
// Register for full type inference everywhere
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
function App() {
return <RouterProvider router={router} />
}
That declare module block is what makes the magic happen. After that, every Link, useNavigate, and useParams call in your entire app is fully typed.
The file structure under src/routes/ maps directly to your URL structure.
src/routes/
├── __root.tsx → root layout (always renders)
├── index.tsx → /
├── about.tsx → /about
├── posts/
│ ├── index.tsx → /posts
│ └── $postId.tsx → /posts/:postId
└── _authenticated/
├── _authenticated.tsx → layout (no URL segment)
└── dashboard.tsx → /dashboard
The root route is the shell that wraps everything. It always renders.
// src/routes/__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/posts">Posts</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
<main>
<Outlet /> {/* child routes render here */}
</main>
</div>
),
notFoundComponent: () => <div>404 — Page not found</div>,
})
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: AboutPage,
})
function AboutPage() {
return <h1>About</h1>
}
Use $ prefix for dynamic segments:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams() // fully typed
return <h1>Post: {postId}</h1>
}
This is where TanStack Router really shines. Loaders run before your component renders. No loading spinners on first paint. No empty shells.
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
async function fetchPost(postId: string) {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error('Post not found')
return res.json()
}
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return fetchPost(params.postId)
},
pendingComponent: () => <div>Loading post...</div>,
errorComponent: ({ error }) => <div>Error: {error.message}</div>,
component: PostPage,
})
function PostPage() {
const post = Route.useLoaderData() // typed from loader return value
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
The loader data is cached. Navigate away and back? The data is still there. No refetch unless it's stale.
If you're already using TanStack Query, the integration is seamless. Use the loader to prefetch, and useQuery to consume:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()),
})
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(postQueryOptions(params.postId)),
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <h1>{post.title}</h1>
}
The loader ensures the data is in the cache before the component renders. useSuspenseQuery reads it. No double fetch. No loading state.
Search params are the most underused feature in routing. Most apps use useState for filters, pagination, and sort order. Then the user refreshes and loses everything.
TanStack Router treats search params as first-class state. Validated, typed, and serialized automatically.
// src/routes/posts/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { fallback } from '@tanstack/zod-adapter'
const postsSearchSchema = z.object({
page: fallback(z.number(), 1).default(1),
category: fallback(z.string(), 'all').default('all'),
sort: fallback(z.enum(['newest', 'oldest', 'popular']), 'newest').default('newest'),
})
export const Route = createFileRoute('/posts/')({
validateSearch: postsSearchSchema,
component: PostsPage,
})
function PostsPage() {
const { page, category, sort } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
return (
<div>
<select
value={sort}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, sort: e.target.value, page: 1 }),
})
}
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Popular</option>
</select>
{/* Pagination */}
<Link search={(prev) => ({ ...prev, page: page + 1 })}>
Next Page →
</Link>
</div>
)
}
The URL becomes /posts?page=2&category=react&sort=newest. Refresh the page? Everything is still there. Share the URL? The other person sees the same filtered view.
And if someone manually types ?page=abc in the URL? The fallback wrapper catches it and resets to the default. No crashes.
Route context lets you inject data at any level of the route tree. Every child route can access it.
The most common use case: passing queryClient and auth state to all routes.
// src/main.tsx
const router = createRouter({
routeTree,
context: {
queryClient,
auth: undefined!, // will be set by the provider
},
});
// src/routes/__root.tsx
import { createRootRouteWithContext } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
interface RouterContext {
queryClient: QueryClient;
auth: { isAuthenticated: boolean; user: User | null };
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
Now every route in your app can access queryClient and auth from context:
// src/routes/posts/$postId.tsx
export const Route = createFileRoute("/posts/$postId")({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(postQueryOptions(params.postId)),
});
You can also add to the context in any route and pass it down:
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
// Add user to context for all child routes
return { user: context.auth.user }
},
})
// src/routes/_authenticated/dashboard.tsx
function DashboardPage() {
const { user } = Route.useRouteContext() // has user from parent
return <h1>Welcome, {user.name}</h1>
}
Sometimes you want a shared layout or shared logic for a group of routes, without adding a URL segment. That's what pathless routes are for.
Name the file with an underscore prefix: _authenticated.tsx. It wraps all routes inside _authenticated/ but doesn't add anything to the URL.
// src/routes/_authenticated.tsx
import { 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 }, // save current URL
})
}
return { user: context.auth.user }
},
component: () => <Outlet />, // or a full layout component
})
Any route inside src/routes/_authenticated/ is now protected. No repetition. No forgetting to add an auth check.
Use parentheses to group routes for organization without affecting the URL:
src/routes/
├── (auth)/
│ ├── login.tsx → /login
│ ├── register.tsx → /register
│ └── forgot-password.tsx → /forgot-password
└── (marketing)/
├── index.tsx → /
└── about.tsx → /about
The (auth) and (marketing) folders are invisible to the URL. They're just for organizing your files.
Link is fully typed. Wrong path? TypeScript error. Missing param? TypeScript error.
import { Link } from '@tanstack/react-router'
function Navigation() {
return (
<nav>
{/* Basic link */}
<Link to="/about">About</Link>
{/* Dynamic param — TypeScript requires postId */}
<Link to="/posts/$postId" params={{ postId: '123' }}>
View Post
</Link>
{/* With search params */}
<Link to="/posts" search={{ page: 1, category: 'react' }}>
React Posts
</Link>
{/* Update search params without losing others */}
<Link to="." search={(prev) => ({ ...prev, page: 2 })}>
Next Page
</Link>
{/* Active styling */}
<Link
to="/dashboard"
activeProps={{ className: 'font-bold text-blue-600' }}
inactiveProps={{ className: 'text-gray-500' }}
>
Dashboard
</Link>
{/* Preload on hover */}
<Link to="/posts" preload="intent">
Posts
</Link>
</nav>
)
}
The preload="intent" option is powerful. When the user hovers over a link, TanStack Router starts loading the data for that route. By the time they click, the data is already there.
Use useNavigate when you need to navigate after an async operation:
import { useNavigate } from '@tanstack/react-router'
function CreatePostForm() {
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: formData.get('title') }),
})
if (res.ok) {
const { id } = await res.json()
navigate({
to: '/posts/$postId',
params: { postId: id },
replace: true, // don't add to history
})
}
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" />
<button type="submit">Create</button>
</form>
)
}
Every route can define its own error boundary. No need to wrap components manually.
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`)
if (!res.ok) throw new Error('Post not found')
return res.json()
},
errorComponent: ({ error, reset }) => (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
),
component: PostPage,
})
If the loader throws, errorComponent renders. If the component throws, same thing. The rest of your app keeps working.
TanStack Router ships with dedicated devtools. Add them in development:
// src/routes/__root.tsx
import { lazy, Suspense } from 'react'
const TanStackRouterDevtools = lazy(() =>
import('@tanstack/react-router-devtools').then((m) => ({
default: m.TanStackRouterDevtoolsPanel,
}))
)
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && (
<Suspense>
<TanStackRouterDevtools />
</Suspense>
)}
</>
),
})
The devtools show you every route, its params, search params, loader state, and context. Invaluable for debugging.
| Feature | TanStack Router | React Router v6 |
|---|---|---|
| TypeScript path inference | ✅ Full | ❌ None |
| Type-safe params | ✅ | ❌ |
| Type-safe search params | ✅ With Zod | ❌ |
| Built-in data loading | ✅ Loaders | ✅ Loaders |
| Loader caching (SWR) | ✅ Built-in | ❌ |
| File-based routing | ✅ | ❌ (needs Remix) |
| Route context | ✅ | ❌ |
| Search param validation | ✅ Zod | ❌ |
| Devtools | ✅ | ❌ |
The gap is real. If you're building a TypeScript app, TanStack Router is the better choice.
Here's a real-world structure that ties everything together:
src/routes/
├── __root.tsx → root layout + devtools
├── index.tsx → / (homepage)
├── (auth)/
│ ├── login.tsx → /login (redirects if authenticated)
│ └── register.tsx → /register
└── _authenticated/
├── _authenticated.tsx → auth guard + user context
├── dashboard.tsx → /dashboard
└── posts/
├── index.tsx → /posts (with search params: page, category)
└── $postId.tsx → /posts/:postId (with loader)
// src/routes/_authenticated/posts/index.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { fallback } from '@tanstack/zod-adapter'
const postsSearchSchema = z.object({
page: fallback(z.number(), 1).default(1),
category: fallback(z.string(), 'all').default('all'),
})
const postsQueryOptions = (page: number, category: string) =>
queryOptions({
queryKey: ['posts', { page, category }],
queryFn: () =>
fetch(`/api/posts?page=${page}&category=${category}`).then(r => r.json()),
})
export const Route = createFileRoute('/_authenticated/posts/')({
validateSearch: postsSearchSchema,
loader: ({ context: { queryClient }, deps: { page, category } }) =>
queryClient.ensureQueryData(postsQueryOptions(page, category)),
loaderDeps: ({ search: { page, category } }) => ({ page, category }),
component: PostsPage,
})
function PostsPage() {
const { page, category } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const { data: posts } = useSuspenseQuery(postsQueryOptions(page, category))
return (
<div>
<h1>Posts</h1>
<select
value={category}
onChange={(e) =>
navigate({ search: (prev) => ({ ...prev, category: e.target.value, page: 1 }) })
}
>
<option value="all">All</option>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
</li>
))}
</ul>
<div>
<button
disabled={page <= 1}
onClick={() => navigate({ search: (prev) => ({ ...prev, page: page - 1 }) })}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => navigate({ search: (prev) => ({ ...prev, page: page + 1 }) })}
>
Next
</button>
</div>
</div>
)
}
TanStack Router solves the problems that have been annoying React developers for years:
beforeLoad guardsIf you're starting a new React app, use TanStack Router. If you're on React Router and tired of runtime routing bugs, it's worth the migration.
The type safety alone is worth it.
Sources: