Skip to content

Fix: TanStack Router Not Working — Routes Not Matching, Loader Not Running, or Type Errors

FixDevs · (Updated: )

Part of:  React & Frontend 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 page

Or 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 or Next.js, the routing layer is split into two pieces that must agree: the file system (your routes/ directory) and a generated artifact (routeTree.gen.ts). When you save a route file, the Vite plugin re-runs tsr generate and rewrites the artifact. If the artifact is missing, stale, or has compile errors, the entire router silently behaves as if the route does not exist.

The pipeline has four moving parts. The route tree must be generated before routes work — TanStack Router reads your routes/ directory and writes routeTree.gen.ts. Without it, only routes that already existed at the last build are visible. File names map to URL pathsroutes/dashboard.tsx creates /dashboard, routes/posts/$postId.tsx creates /posts/:postId, and the dollar-sign prefix (not [bracket] like Next.js) marks dynamic segments. Loaders return data through the route context — the loader’s return value is available via Route.useLoaderData(). A function with no return statement gives you undefined in the component with no warning. Type safety is generated from the route treeLink’s to prop and useNavigate’s path are typed from routeTree.gen.ts, so type errors mean the artifact was regenerated but TypeScript has not picked up the change yet (restart your editor’s TS server).

The thing that catches most newcomers is that TanStack Router does not coexist with another router on the same page. Trying to embed it inside a Next.js page or a React Router subtree creates duplicate history listeners and breaks navigation. The intended deployment is as the only router in a Vite SPA or via TanStack Start for SSR.

Platform and Environment Differences

TanStack Router’s “happy path” is a Vite + React SPA. The TanStackRouterVite plugin watches your routes/ directory and regenerates the route tree on save, so the dev experience is seamless. On Webpack or Create React App (without ejecting to Vite), you must run npx tsr generate manually or wire it into a pre-dev script — there is no first-party CRA integration. On Next.js, TanStack Router does not work as a drop-in replacement. Next.js owns routing at the framework level, and the App Router’s RSC layer is incompatible with TanStack’s client-side route tree. If you need TanStack-style type safety in a Next.js project, use TanStack Query for data and stick with the App Router for navigation.

For server-side rendering, use TanStack Start (the official SSR framework built on top of TanStack Router). It handles loader execution on the server, streaming, and hydration. Without TanStack Start, loaders run only on the client, so the initial HTML ships empty and the user sees a flash before data arrives. React Native is supported via @tanstack/react-router plus a native history adapter, but file-based routing is not — you use the code-based API and define routes as an array. For the React Native variant of file-based routing, projects typically prefer Expo Router; see Fix: Expo Router Not Working for setup.

The biggest environment-specific quirk is type-safe params per environment. The generated routeTree.gen.ts is committed to your repo, but generation runs on the machine that has the Vite plugin active. CI builds that do not regenerate the tree (because they build from the committed artifact) can fall out of sync with route files that were added in the same PR — make tsr generate part of your build script, or commit the artifact and verify it in CI. Also note that search param schemas validated with Zod or Valibot run on every navigation. Throwing inside validateSearch triggers the route’s error component instead of the actual page, which can look like the route is “not matching” when it is actually catching invalid query strings.

Fix 1: Set Up File-Based Routing

npm install @tanstack/react-router @tanstack/router-plugin @tanstack/react-router-devtools

Vite 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 generate

Fix 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.

useLoaderData returns the wrong route’s data after navigation — you imported Route from a different route file. Each route file exports its own Route constant, and Route.useLoaderData() only returns that route’s data. To read a parent route’s loader data, use getRouteApi('/parent-path').useLoaderData().

validateSearch throws and breaks navigation — your Zod schema is strict, but a stale URL has an extra param the schema rejects. Use .catch() on each field or wrap the schema in .passthrough() so unknown params are kept rather than rejected.

Path aliases break the generated route tree — the Vite plugin resolves imports relative to your vite.config.ts. If your route files use TypeScript path aliases like @/lib/auth, the plugin needs the matching vite-tsconfig-paths config so the generator can follow the imports during code generation.

For related routing and data-fetching patterns, see Fix: React Router 7 Not Working, Fix: TanStack Start Not Working, and Fix: TanStack Query Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles