writing/tutorial/2026/05
TutorialMay 7, 2026·28 min read

TanStack Router v1: Type-Safe File-Based Routing for React in 2026

Master TanStack Router v1, the fully type-safe file-based router for React. Build a real application with nested routes, search-param validation, data loaders, and pending UI — with end-to-end TypeScript inference.

TanStack Router has matured into the most rigorously type-safe router in the React ecosystem. Unlike React Router, where path strings are stringly-typed and search params are an afterthought, TanStack Router treats every route, every param, and every search-state shape as a first-class TypeScript citizen. If you misspell a route, pass the wrong param type, or forget a required search param, the compiler stops you before the bundle ships.

In this tutorial you will build a small but realistic dashboard application — products list, product detail, filtered search — using TanStack Router v1 with file-based routing, route loaders, search-param schemas, and pending UI. By the end you will understand the mental model well enough to drop the router into a production codebase.

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or later installed
  • A working knowledge of React 18 or 19 hooks
  • Comfort with TypeScript generics at a beginner-to-intermediate level
  • A code editor with strong TypeScript support (VS Code, WebStorm)
  • Roughly 30 minutes of focused time

You do not need prior experience with TanStack Query, Start, or DB — Router is fully usable on its own.

What You'll Build

A two-page dashboard with:

  • A products listing page at /products with type-safe URL search params for filtering
  • A product detail page at /products/$productId with a route-level data loader
  • A nested layout that shares a sidebar across all dashboard routes
  • Pending UI driven by useNavigation-style hooks for instant feedback during transitions
  • Fully inferred Link components — no string typos possible

Everything runs locally on Vite. No backend required.

Step 1: Project Setup

Create a fresh Vite React TypeScript project and install the router packages.

npm create vite@latest tanstack-router-demo -- --template react-ts
cd tanstack-router-demo
npm install
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin @tanstack/router-devtools

The two packages of interest:

  • @tanstack/react-router — the runtime router
  • @tanstack/router-plugin — Vite plugin that generates the route tree from your file structure
  • @tanstack/router-devtools — an in-browser inspector for active routes, loaders, and matched params

Open vite.config.ts and wire in the plugin. The plugin must run before the React plugin so route generation happens first.

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(),
  ],
});

autoCodeSplitting: true is the recommended default in v1 — it lazily splits each route file into its own chunk without you writing a single dynamic import.

Step 2: Define the Root Route

TanStack Router uses a convention-driven file layout under src/routes/. Create that directory and add the entry point.

mkdir -p src/routes

Create src/routes/__root.tsx. The double-underscore prefix marks the file as the root route — every other route in the tree nests inside it.

import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
 
export const Route = createRootRoute({
  component: RootLayout,
});
 
function RootLayout() {
  return (
    <div className="app">
      <header>
        <nav>
          <Link to="/" activeProps={{ className: "active" }}>
            Home
          </Link>
          <Link to="/products" activeProps={{ className: "active" }}>
            Products
          </Link>
        </nav>
      </header>
      <main>
        <Outlet />
      </main>
      <TanStackRouterDevtools position="bottom-right" />
    </div>
  );
}

Outlet is where child routes render. The devtools mount once at the root and appear as a floating button in the corner.

Step 3: Add the Index Route

Create src/routes/index.tsx for the home page.

import { createFileRoute } from "@tanstack/react-router";
 
export const Route = createFileRoute("/")({
  component: HomePage,
});
 
function HomePage() {
  return (
    <section>
      <h1>Dashboard</h1>
      <p>Welcome. Visit the Products page to begin.</p>
    </section>
  );
}

Notice the literal route path passed to createFileRoute("/"). This must match the file path or the build will fail — the plugin enforces it. That literal is also what makes Link to="/" type-safe: only paths the plugin has seen are valid.

Step 4: Bootstrap the Router

Now wire the generated route tree into the React entry point. Replace src/main.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
 
const router = createRouter({ routeTree });
 
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}
 
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

Two important details:

The routeTree.gen.ts file is auto-generated by the Vite plugin on the first build or dev run. If your editor flags it as missing, run npm run dev once and it will appear.

The declare module block registers your router instance with TanStack Router's type system. After this, hooks like useNavigate and useParams know about every route in your application, parameter by parameter.

Run the dev server and confirm the home page loads.

npm run dev

Step 5: Build the Products List with Search Params

This is where TanStack Router pulls ahead of competitors. URL search params are typed, validated, and parsed automatically.

Create src/routes/products.tsx. Note that this is a layout route — it has children — so we use a non-leaf path.

import { createFileRoute, Outlet } from "@tanstack/react-router";
import { z } from "zod";
 
const productSearchSchema = z.object({
  category: z.enum(["all", "books", "electronics", "clothing"]).catch("all"),
  page: z.number().int().positive().catch(1),
  sort: z.enum(["name", "price"]).catch("name"),
});
 
export const Route = createFileRoute("/products")({
  validateSearch: productSearchSchema,
  component: ProductsLayout,
});
 
function ProductsLayout() {
  return (
    <div className="products-layout">
      <aside>
        <h2>Filters</h2>
      </aside>
      <Outlet />
    </div>
  );
}

Install Zod if you have not already.

npm install zod

validateSearch runs on every navigation. The .catch() calls supply fallbacks for missing or malformed params instead of throwing — a small detail that saves enormous debugging time once your app is in production.

Now create the index of the products section: src/routes/products.index.tsx. The dot syntax in the filename means "the index route nested inside /products".

import { createFileRoute, Link } from "@tanstack/react-router";
 
const allProducts = [
  { id: "p1", name: "Pragmatic Programmer", category: "books", price: 32 },
  { id: "p2", name: "Mechanical Keyboard", category: "electronics", price: 145 },
  { id: "p3", name: "Hoodie", category: "clothing", price: 65 },
  { id: "p4", name: "Designing Data-Intensive Apps", category: "books", price: 48 },
];
 
export const Route = createFileRoute("/products/")({
  component: ProductsList,
});
 
function ProductsList() {
  const { category, page, sort } = Route.useSearch();
 
  const filtered = allProducts
    .filter((p) => category === "all" || p.category === category)
    .sort((a, b) => (sort === "price" ? a.price - b.price : a.name.localeCompare(b.name)));
 
  return (
    <section>
      <header>
        <h1>Products</h1>
        <nav className="filter-bar">
          <Link from={Route.fullPath} search={(prev) => ({ ...prev, category: "all" })}>
            All
          </Link>
          <Link from={Route.fullPath} search={(prev) => ({ ...prev, category: "books" })}>
            Books
          </Link>
          <Link from={Route.fullPath} search={(prev) => ({ ...prev, category: "electronics" })}>
            Electronics
          </Link>
        </nav>
        <p>
          Showing page {page}, sorted by {sort}
        </p>
      </header>
      <ul>
        {filtered.map((p) => (
          <li key={p.id}>
            <Link to="/products/$productId" params={{ productId: p.id }}>
              {p.name} — ${p.price}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

Two patterns worth highlighting:

Route.useSearch() returns a fully typed object derived from the Zod schema. There is no manual parsing. There is no string-keyed access. If you rename a search param, the compiler points to every consumer.

The functional form search={(prev) => ({ ...prev, category: "books" })} lets you update one search param while preserving others. Cleaner than concatenating query strings by hand.

Step 6: Add the Product Detail Route with a Loader

Create src/routes/products.$productId.tsx. The $productId segment marks a dynamic param.

import { createFileRoute, Link, notFound } from "@tanstack/react-router";
 
type Product = { id: string; name: string; category: string; price: number };
 
const productDb: Record<string, Product> = {
  p1: { id: "p1", name: "Pragmatic Programmer", category: "books", price: 32 },
  p2: { id: "p2", name: "Mechanical Keyboard", category: "electronics", price: 145 },
  p3: { id: "p3", name: "Hoodie", category: "clothing", price: 65 },
  p4: { id: "p4", name: "Designing Data-Intensive Apps", category: "books", price: 48 },
};
 
export const Route = createFileRoute("/products/$productId")({
  loader: async ({ params }) => {
    await new Promise((r) => setTimeout(r, 300));
    const product = productDb[params.productId];
    if (!product) throw notFound();
    return { product };
  },
  pendingComponent: () => <p>Loading product…</p>,
  notFoundComponent: () => <p>That product does not exist.</p>,
  component: ProductDetail,
});
 
function ProductDetail() {
  const { product } = Route.useLoaderData();
 
  return (
    <article>
      <Link to="/products" search={{ category: "all", page: 1, sort: "name" }}>
Back
      </Link>
      <h1>{product.name}</h1>
      <p>Category: {product.category}</p>
      <p>Price: ${product.price}</p>
    </article>
  );
}

The loader runs before the component renders. While it is in flight, pendingComponent is shown automatically — no manual loading flag needed. If the loader throws notFound(), notFoundComponent takes over. This is the same composable model Remix popularized, but with end-to-end TypeScript inference on Route.useLoaderData().

Step 7: Pending UI on Slow Navigations

By default, TanStack Router waits for loaders to resolve before swapping the view, which can feel laggy. Tune this with defaultPendingMs and defaultPreload on the router itself. Update src/main.tsx:

const router = createRouter({
  routeTree,
  defaultPreload: "intent",
  defaultPreloadStaleTime: 30_000,
  defaultPendingMs: 200,
  defaultPendingMinMs: 500,
});

What each does:

  • defaultPreload: "intent" starts loading on link hover and focus, so the data is often ready by the time the user actually clicks
  • defaultPreloadStaleTime prevents redundant preloads within 30 seconds
  • defaultPendingMs waits 200ms before showing the pending UI — fast loads never flash the spinner
  • defaultPendingMinMs ensures the spinner sticks around at least 500ms once shown, preventing flicker

These four lines are the difference between an app that feels sluggish and one that feels precognitive.

Step 8: Programmatic Navigation

Inside any component you can use useNavigate for imperative navigation. The hook is fully typed against the route tree.

import { useNavigate } from "@tanstack/react-router";
 
function CheckoutButton({ productId }: { productId: string }) {
  const navigate = useNavigate();
 
  return (
    <button
      onClick={() =>
        navigate({
          to: "/products/$productId",
          params: { productId },
        })
      }
    >
      Buy now
    </button>
  );
}

Try renaming $productId to $slug in the route file. The TypeScript error trail leads you to every navigation call that needs updating — refactoring becomes mechanical instead of risky.

Testing Your Implementation

Walk through this checklist in the browser:

  • Open /. The home page renders with the navbar.
  • Click Products. The list appears with filter buttons.
  • Click Books. The URL becomes /products?category=books&page=1&sort=name and the list filters.
  • Manually edit the URL to /products?category=spaceships. The page renders with category: "all" because the schema fell back gracefully.
  • Click a product. Notice the brief pending state, then the detail page.
  • Try /products/nonexistent directly. The not-found component renders.
  • Open the devtools panel (bottom right) and inspect the matched routes, loader data, and search params live.

If all eight pass, the router is wired correctly.

Troubleshooting

routeTree.gen.ts is missing. Run the dev server once. The Vite plugin generates it on first run and rewrites it on every file save in src/routes/.

TypeScript thinks every route is string. You forgot the declare module block in main.tsx. Without it, the type system has no idea your router exists.

Link to="/foo" does not autocomplete. The route file probably failed to be picked up. Check that the file lives under src/routes/, exports Route, and that the dev server has restarted at least once.

Search params keep resetting. You used the object form search={{ category: "books" }} instead of the function form search={(prev) => ({ ...prev, category: "books" })}. The object form replaces all params; the function form merges.

Next Steps

  • Pair the router with TanStack Queryloader becomes a thin wrapper that calls queryClient.ensureQueryData() for full cache integration
  • Explore TanStack Start when you need server functions, SSR, and streaming on top of the same router
  • Read the official Code-Based vs File-Based comparison if you want a second opinion before committing your codebase

Conclusion

TanStack Router replaces stringly-typed routing with a compiler-checked tree. Path mismatches, missing params, and malformed search states all become build errors instead of runtime bugs. Combine that with built-in pending UI, intent-based preloading, and Zod-powered search validation, and you have a router that genuinely makes React applications feel faster — both to ship and to use.

The dashboard you just built is small, but the patterns scale. Nested layouts compose. Search schemas compose. Loaders compose. Drop this router into your next medium-sized React app and you will not miss the alternatives.