DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

TanStack Router Complete Guide 2026: Type-Safe React Routing

React Router v7 rebranded as Remix. The team is focused on full-stack React with Remix, not client-side routing. For developers building React SPAs — or React apps that don't live inside Next.js — the ecosystem left a gap, and TanStack Router stepped in.

TanStack Router is built from the ground up with TypeScript. Route paths, route params, search params, loaders, and even <Link> props are all type-checked by the compiler. If you change a route's params, TypeScript finds every broken link in your codebase immediately.

This guide covers a complete setup: file-based routing, type-safe navigation, search params with Zod, route loaders, and error boundaries.


Why TanStack Router is gaining momentum

The core problem with React Router v6 and earlier:

// React Router — string-based, no type safety
<Link to="/users/123/posts?filter=published">Posts</Link>

// If you change the route to /user/:id/posts (missing 's')
// TypeScript finds nothing. Runtime error in production.
Enter fullscreen mode Exit fullscreen mode
// TanStack Router — fully typed
<Link to="/users/$userId/posts" params={{ userId: '123' }} search={{ filter: 'published' }}>
  Posts
</Link>

// Change the route? TypeScript errors everywhere it's used.
// Refactor routes safely across the entire codebase.
Enter fullscreen mode Exit fullscreen mode

This isn't a cosmetic improvement. In large codebases with many routes, type-safe navigation means route refactors that took hours of grep-and-pray become safe, compiler-verified operations.


Installation

npm install @tanstack/react-router
npm install -D @tanstack/router-devtools @tanstack/router-plugin
Enter fullscreen mode Exit fullscreen mode

For file-based routing (recommended), install the Vite plugin:

npm install -D @tanstack/router-plugin
Enter fullscreen mode Exit fullscreen mode

Setup with Vite (file-based routing)

// 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 plugin
    react(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

File-based routing uses a src/routes/ directory. The plugin auto-generates the route tree from your files — no manual route registration.

Route file structure

src/routes/
├── __root.tsx          # Root layout (always rendered)
├── index.tsx           # / route
├── about.tsx           # /about route
├── users/
│   ├── index.tsx       # /users route
│   ├── $userId.tsx     # /users/:userId (dynamic param)
│   └── $userId/
│       └── posts.tsx   # /users/:userId/posts
└── _auth/              # Layout group (no URL segment)
    ├── dashboard.tsx   # /dashboard
    └── settings.tsx    # /settings
Enter fullscreen mode Exit fullscreen mode

Underscore prefix (_auth) creates a layout group that wraps routes without adding a URL segment.


Root layout

// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';

export const Route = createRootRoute({
  component: () => (
    <div>
      <nav>
        <Link to="/" className="[&.active]:font-bold">Home</Link>
        <Link to="/about" className="[&.active]:font-bold">About</Link>
        <Link to="/users" className="[&.active]:font-bold">Users</Link>
      </nav>
      <hr />
      <Outlet />
      {/* Development only — remove for production */}
      <TanStackRouterDevtools />
    </div>
  ),
});
Enter fullscreen mode Exit fullscreen mode

<Outlet /> renders the matched child route. The [&.active] className pattern applies styles when the link is active — TanStack Router adds the active class automatically.


Dynamic params

// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/users/$userId')({
  component: UserPage,
});

function UserPage() {
  // Fully typed — TypeScript knows userId is a string
  const { userId } = Route.useParams();

  return <div>User ID: {userId}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Route.useParams() is typed to the route's params. No useParams<{ userId: string }>() casting needed — TanStack Router infers the type from the route definition.


Route loaders

Route loaders fetch data before the component renders. They run in parallel when possible and the component only renders when the data is ready.

// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { fetchUser } from '@/api/users';

export const Route = createFileRoute('/users/$userId')({
  loader: async ({ params }) => {
    // params.userId is typed as string
    const user = await fetchUser(params.userId);
    return { user };
  },
  component: UserPage,
});

function UserPage() {
  // loaderData is typed from the loader's return value
  const { user } = Route.useLoaderData();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Route.useLoaderData() is typed from the loader's return value. TypeScript knows the shape of user without any manual type declarations.

Parallel loaders

When navigating to a route, all ancestor loaders run in parallel:

/users/$userId/posts
↓
Root loader → User loader → Posts loader
             ↑ these run simultaneously
Enter fullscreen mode Exit fullscreen mode

This is a significant performance improvement over waterfall fetching in useEffect.


Type-safe search params

Search params in React Router are strings. Parse them yourself. Validate them yourself. No type safety.

TanStack Router validates search params with a schema and returns typed values:

// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';

const searchSchema = z.object({
  page: z.number().int().min(1).default(1),
  filter: z.enum(['active', 'inactive', 'all']).default('all'),
  search: z.string().optional(),
});

export const Route = createFileRoute('/users/')({
  validateSearch: searchSchema,
  component: UsersPage,
});

function UsersPage() {
  // search is typed from the schema — no casting
  const { page, filter, search } = Route.useSearch();

  return (
    <div>
      <p>Page {page}  Filter: {filter}</p>
      {search && <p>Searching for: {search}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And navigating with search params is also typed:

// Typed — TypeScript enforces the search param schema
<Link to="/users" search={{ page: 2, filter: 'active' }}>
  Next page
</Link>
Enter fullscreen mode Exit fullscreen mode

If you pass filter: 'invalid' or page: 'string', TypeScript catches it at compile time.


Error boundaries and pending states

export const Route = createFileRoute('/users/$userId')({
  loader: async ({ params }) => {
    const user = await fetchUser(params.userId);
    if (!user) throw new Error('User not found');
    return { user };
  },
  // Error boundary for this route
  errorComponent: ({ error }) => (
    <div>
      <h2>Failed to load user</h2>
      <p>{error.message}</p>
    </div>
  ),
  // Pending UI while loader runs
  pendingComponent: () => <div>Loading user...</div>,
  component: UserPage,
});
Enter fullscreen mode Exit fullscreen mode

Each route has its own error and pending boundaries. An error in /users/$userId doesn't crash /users — it only affects that route's slot.


Protected routes with layout groups

// src/routes/_auth.tsx — layout group, no URL segment
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getSession } from '@/lib/auth';

export const Route = createFileRoute('/_auth')({
  beforeLoad: async ({ context }) => {
    const session = await getSession();
    if (!session) {
      throw redirect({ to: '/sign-in', search: { redirect: location.href } });
    }
  },
  component: ({ children }) => <>{children}</>,
});
Enter fullscreen mode Exit fullscreen mode

All routes under _auth/ run this beforeLoad check. Routes that require auth: put them in src/routes/_auth/.

// src/routes/_auth/dashboard.tsx
export const Route = createFileRoute('/_auth/dashboard')({
  component: DashboardPage,
});
// URL is /dashboard, not /_auth/dashboard
Enter fullscreen mode Exit fullscreen mode

The router setup

// src/main.tsx
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen'; // auto-generated by the plugin

const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // preload on hover
});

// Type registration for type-safe Link
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

routeTree.gen.ts is auto-generated when you run Vite. You never edit it manually — the plugin regenerates it when you add, remove, or rename route files.


TanStack Router vs React Router — when to choose each

TanStack Router React Router v7 (Remix)
Type safety End-to-end Partial
Search params Typed + validated Strings
SSR Via TanStack Start Built-in (Remix)
Full-stack TanStack Start (newer) Remix (mature)
Loaders Built-in, parallel Via framework
File-based routing
Community size Growing fast Large, established

Choose TanStack Router for client-side React SPAs, Vite-based apps, or any project where TypeScript correctness across routes matters.

Choose React Router / Remix if you want a mature full-stack framework with server-side rendering and a large ecosystem of tutorials and patterns.

Don't use TanStack Router in Next.js — Next.js has its own router. TanStack Router is for non-Next.js React apps.


Full article with more detail: TanStack Router Complete Guide 2026

Related: TanStack Query v5 Complete Guide · React Hook Form + Zod Guide · Next.js App Router Guide

Top comments (0)