Fix: TanStack Router Not Working — Routes Not Matching, Loader Not Running, or Type Errors
Quick Answer
How to fix TanStack Router issues — file-based routing setup, route tree generation, loader and search params, authenticated routes, type-safe navigation, and code splitting.
The Problem
You define a route but navigating to it shows a blank page or the 404 fallback:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/dashboard')({
component: Dashboard,
});
function Dashboard() {
return <h1>Dashboard</h1>;
}
// Navigate to /dashboard — blank pageOr the route loader runs but data is undefined in the component:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
return post;
},
component: PostPage,
});
function PostPage() {
const data = Route.useLoaderData();
// data is undefined
}Or TypeScript throws type errors on Link or useNavigate:
Type '"dashbord"' is not assignable to type '"/dashboard" | "/posts/$postId" | ...'Why This Happens
TanStack Router is a fully type-safe router that generates its route tree from the file system. Unlike React Router, most of its power comes from build-time code generation:
- The route tree must be generated before routes work — TanStack Router reads your
routes/directory and generates arouteTree.gen.tsfile. If this file is missing or outdated, routes silently don’t exist. Runningtsr generateor having the Vite plugin active is required. - File names map to URL paths —
routes/dashboard.tsxcreates/dashboard,routes/posts/$postId.tsxcreates/posts/:postId. A mismatch between the file name and the URL you’re navigating to means no route matches. - Loaders return data through the route context — the loader’s return value is available via
Route.useLoaderData(). If the loader throws, the error boundary catches it. If the loader returnsundefined(e.g., a function with no return statement), the data is undefined. - Type safety is generated from the route tree —
Link’stoprop,useNavigate’s path, anduseParamsare all typed fromrouteTree.gen.ts. Typos trigger compile errors, but only if the route tree is up to date.
Fix 1: Set Up File-Based Routing
npm install @tanstack/react-router @tanstack/router-plugin @tanstack/react-router-devtoolsVite plugin (auto-generates route tree):
// 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(), // Must be before react()
react(),
],
});Router setup:
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';
// Import the GENERATED route tree — not hand-written
import { routeTree } from './routeTree.gen';
const router = createRouter({ routeTree });
// Register the router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);Root route:
// src/routes/__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/" className="[&.active]:font-bold">Home</Link>
<Link to="/dashboard" className="[&.active]:font-bold">Dashboard</Link>
<Link to="/posts" className="[&.active]:font-bold">Posts</Link>
</nav>
<Outlet />
<TanStackRouterDevtools />
</>
),
notFoundComponent: () => <div>404 — Page not found</div>,
});Index route:
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: HomePage,
});
function HomePage() {
return <h1>Home</h1>;
}Generate the route tree manually (if not using the Vite plugin):
npx tsr generateFix 2: Route Loaders and Data Fetching
Loaders run before the component renders — blocking navigation until data is ready:
// src/routes/posts.tsx — list route
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetch('/api/posts').then(r => r.json());
return { posts }; // Must return an object or value
},
component: PostsPage,
});
function PostsPage() {
const { posts } = Route.useLoaderData();
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
</li>
))}
</ul>
);
}
// src/routes/posts/$postId.tsx — detail route with params
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// params.postId is type-safe — always a string
const post = await fetch(`/api/posts/${params.postId}`).then(r => {
if (!r.ok) throw new Error('Post not found');
return r.json();
});
return { post };
},
component: PostDetail,
errorComponent: ({ error }) => (
<div>Error: {(error as Error).message}</div>
),
});
function PostDetail() {
const { post } = Route.useLoaderData();
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}Use context to pass dependencies (API clients, auth tokens):
// src/main.tsx — provide context at the router level
const router = createRouter({
routeTree,
context: {
auth: undefined!, // Will be set by the provider
},
});
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router';
interface RouterContext {
auth: { userId: string; token: string } | null;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
// src/routes/posts/$postId.tsx — access context in loader
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
// context.auth is type-safe
const res = await fetch(`/api/posts/${params.postId}`, {
headers: { Authorization: `Bearer ${context.auth?.token}` },
});
return { post: await res.json() };
},
component: PostDetail,
});Fix 3: Search Params (Query Strings)
TanStack Router treats search params as first-class, type-safe data:
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
// Define search params schema with Zod
const postsSearchSchema = z.object({
page: z.number().catch(1),
sort: z.enum(['newest', 'oldest', 'popular']).catch('newest'),
q: z.string().optional(),
});
type PostsSearch = z.infer<typeof postsSearchSchema>;
export const Route = createFileRoute('/posts')({
validateSearch: postsSearchSchema, // Auto-parse and validate from URL
loaderDeps: ({ search }) => ({ page: search.page, sort: search.sort }),
loader: async ({ deps }) => {
// deps.page and deps.sort trigger a reload when they change
const posts = await fetch(
`/api/posts?page=${deps.page}&sort=${deps.sort}`
).then(r => r.json());
return { posts };
},
component: PostsPage,
});
function PostsPage() {
const { posts } = Route.useLoaderData();
const { page, sort, q } = Route.useSearch();
const navigate = Route.useNavigate();
return (
<div>
{/* Update search params — type-safe */}
<select
value={sort}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, sort: e.target.value as PostsSearch['sort'] }),
})
}
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Popular</option>
</select>
<input
value={q ?? ''}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, q: e.target.value || undefined }),
})
}
placeholder="Search..."
/>
{/* Link with search params */}
<Link to="/posts" search={{ page: page + 1, sort }}>
Next Page
</Link>
</div>
);
}Fix 4: Layout Routes and Nested Outlets
File naming conventions control layout nesting:
src/routes/
├── __root.tsx # Root layout (nav, footer)
├── index.tsx # /
├── _auth.tsx # Layout route — no URL segment
├── _auth/ # Children share _auth layout
│ ├── dashboard.tsx # /dashboard (wrapped in _auth layout)
│ └── settings.tsx # /settings (wrapped in _auth layout)
├── posts.tsx # /posts (layout for post routes)
├── posts/
│ ├── index.tsx # /posts (list view — renders in posts.tsx Outlet)
│ └── $postId.tsx # /posts/:postId (detail view)
└── about.tsx # /about// src/routes/_auth.tsx — layout route (underscore prefix = no URL segment)
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_auth')({
beforeLoad: async ({ context }) => {
if (!context.auth) {
throw redirect({ to: '/login', search: { redirect: location.pathname } });
}
},
component: () => (
<div className="flex">
<aside>Sidebar</aside>
<main><Outlet /></main>
</div>
),
});
// src/routes/_auth/dashboard.tsx — protected by _auth layout
export const Route = createFileRoute('/_auth/dashboard')({
component: () => <h1>Dashboard</h1>,
});
// src/routes/posts.tsx — parent layout for /posts/*
export const Route = createFileRoute('/posts')({
component: () => (
<div>
<h1>Posts</h1>
<Outlet /> {/* Renders index.tsx or $postId.tsx */}
</div>
),
});Fix 5: Type-Safe Navigation
Link and useNavigate are fully typed from the route tree:
import { Link, useNavigate } from '@tanstack/react-router';
// Link — compile-time checked paths
<Link to="/posts/$postId" params={{ postId: '123' }}>
View Post
</Link>
// Type error — typo in path
<Link to="/pots/$postId" params={{ postId: '123' }}> {/* TS Error */}
// Type error — missing required param
<Link to="/posts/$postId"> {/* TS Error: params is required */}
// Navigate programmatically
function PostActions({ postId }: { postId: string }) {
const navigate = useNavigate();
async function handleDelete() {
await deletePost(postId);
navigate({ to: '/posts' });
}
async function handleEdit() {
// Navigate with params and search
navigate({
to: '/posts/$postId',
params: { postId },
search: { edit: true },
});
}
return (
<div>
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
);
}
// Active link styling — [&.active] selector
<Link
to="/dashboard"
className="text-gray-500 [&.active]:text-blue-600 [&.active]:font-bold"
>
Dashboard
</Link>
// Or use the render prop form for more control
<Link to="/dashboard">
{({ isActive }) => (
<span className={isActive ? 'font-bold text-blue-600' : 'text-gray-500'}>
Dashboard
</span>
)}
</Link>Fix 6: Code Splitting and Lazy Loading
Split large routes to reduce initial bundle size:
// src/routes/dashboard.tsx — lazy component
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/dashboard')({
component: () => import('./dashboard-component').then(m => m.Dashboard),
});
// Better approach — use the .lazy.tsx convention.lazy.tsx file convention (recommended):
// src/routes/dashboard.tsx — critical route config (stays in main bundle)
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/dashboard')({
loader: async () => {
return { stats: await fetchStats() };
},
// No component here — it's in the .lazy file
});
// src/routes/dashboard.lazy.tsx — lazy-loaded component
import { createLazyFileRoute } from '@tanstack/react-router';
export const Route = createLazyFileRoute('/dashboard')({
component: Dashboard,
pendingComponent: () => <div>Loading dashboard...</div>,
errorComponent: ({ error }) => <div>Error: {error.message}</div>,
});
function Dashboard() {
const { stats } = Route.useLoaderData();
return <div>...</div>;
}The .lazy.tsx file is code-split automatically. The loader stays in the main bundle so data fetching starts immediately, while the component code loads in parallel.
Still Not Working?
routeTree.gen.ts is empty or missing routes — the Vite plugin or tsr generate didn’t pick up your files. Check that route files are in the correct directory (default: src/routes/). Check tsr.config.json if you’ve customized the routes directory. Also verify each route file exports const Route = createFileRoute(...) — a missing or misspelled export is silently ignored.
Loader runs but component gets stale data — loaders are cached by default. If you navigate away and back, the cached data is returned. Use shouldReload or gcTime to control staleness: shouldReload: true always re-runs the loader. For TanStack Query integration, use ensureQueryData in the loader and let Query handle caching.
$param route doesn’t match — the file must be named with the dollar sign: $postId.tsx, not [postId].tsx (that’s Next.js convention). The dollar sign prefix is what TanStack Router uses to denote dynamic segments. Also check that the param name in your loader (params.postId) matches the file name ($postId).
Redirects cause infinite loops — if beforeLoad throws redirect to a route whose own beforeLoad also redirects back, you get an infinite loop. Add a condition check: if you’re already on the target route, don’t redirect. Also check that the redirect target isn’t also protected by the same auth guard.
For related routing issues, see Fix: React Router Not Working and Fix: Next.js App Router Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.