Pavan Rangani

HomeBlogTanStack Router: Type-Safe Routing for Modern React Apps

TanStack Router: Type-Safe Routing for Modern React Apps

By Pavan Rangani · May 8, 2026 · Web Development

TanStack Router: Type-Safe Routing for Modern React Apps

Why TanStack Router Type-Safe Routing Matters Now

For nine years I shipped React apps with React Router, and for nine years I accepted runtime route bugs as a cost of doing business. TanStack Router type-safe routing finally eliminated that category of error in our codebase, replacing string-based navigation with end-to-end inferred types from URL through search params into loader data. Furthermore, the developer ergonomics rival what we expected only from server-rendered frameworks.

However, TanStack Router is not a drop-in replacement, and the migration is real work. In this guide I will share the patterns from our migration of a 280-route enterprise application, including the gotchas around search param validation, loader composition, and the bundle size implications nobody mentioned in the marketing materials.

The Type-Safety Story in Practice

TanStack Router infers route paths, search params, path params, and loader return types into a single registry. Consequently, when you call navigate({ to: '/invoices/$id', params: { id: '...' }, search: {...} }), TypeScript validates that the route exists, that all required params are present, and that the search shape matches the route’s validator. There are no string concatenations, no manual route constants, no runtime surprises.

To make this work, you need TypeScript 5.4+ and a single registry declaration. The router generates a type tree at build time via the Vite plugin or CLI. Subsequently, your IDE autocompletes routes, params, and search keys everywhere you import the router.

TanStack Router type-safe routing inference
Full inference from route definition through navigate() call sites.

File-Based Routes vs Code-Based Routes

TanStack Router supports both file-based and code-based route trees. File-based uses the @tanstack/router-plugin Vite plugin to walk a src/routes directory and generate the route tree. Code-based requires you to declare every route imperatively. We chose file-based for the 280-route app and code-based for a smaller 30-route admin panel.

File-based wins on discoverability and matches Next.js conventions enough that team members onboard quickly. However, code-based gives you fine-grained control over lazy boundaries and dynamic registration, which mattered for our admin panel that registers routes based on tenant feature flags.

// src/routes/invoices/$invoiceId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { invoiceQueryOptions } from '@/queries/invoices';

const InvoiceSearchSchema = z.object({
  view: z.enum(['summary', 'lines', 'history']).default('summary'),
  highlightLineId: z.string().uuid().optional(),
});

export const Route = createFileRoute('/invoices/$invoiceId')({
  validateSearch: InvoiceSearchSchema,
  loaderDeps: ({ search: { view } }) => ({ view }),
  loader: async ({ params, deps, context }) => {
    const queryClient = context.queryClient;
    return queryClient.ensureQueryData(
      invoiceQueryOptions(params.invoiceId, deps.view),
    );
  },
  errorComponent: ({ error }) => ,
  notFoundComponent: () => ,
  component: InvoiceDetailPage,
});

function InvoiceDetailPage() {
  const { invoiceId } = Route.useParams();
  const { view, highlightLineId } = Route.useSearch();
  const invoice = Route.useLoaderData();
  return ;
}

Search Params as First-Class Citizens

Search params in TanStack Router are validated, typed, and serialized through user-supplied schemas. This is the single feature that made me adopt the library. Specifically, you declare a Zod schema (or any function returning a typed shape) and the router parses, validates, and provides type-safe access to useSearch calls everywhere downstream.

Compared to React Router’s useSearchParams returning URLSearchParams, this is a generational improvement. We replaced 4,000 lines of search-param parsing utilities with about 200 lines of Zod schemas during migration. Additionally, malformed URLs now redirect to a defined fallback instead of crashing components.

Loaders, Preloading, and Data Fetching

Loaders run before route components mount and can block navigation, similar to Remix and React Router 7. The cleanest pattern pairs TanStack Router loaders with TanStack Query: the loader calls queryClient.ensureQueryData, which either returns cached data or triggers the fetch. Subsequently, the component renders with data already in the query cache and uses useSuspenseQuery to access it.

Preloading on link hover is built in via the defaultPreload: 'intent' router option. As a result, perceived navigation latency drops to near-zero on broadband connections. We measured a P50 reduction from 340ms to 60ms on our most-used routes after enabling intent preloading. For complementary patterns, see our Remix v3 nested routes data loading guide.

TanStack Router loaders preloading data
Intent-based preloading collapses the gap between hover and navigation.

Route Context and Dependency Injection

The context object passed to loaders is one of TanStack Router’s underrated features. You provide a typed context at the router level (typically containing the query client, auth helpers, and feature flags), and every loader receives it with full type inference. Therefore, you avoid the dance of importing singletons or accessing React context inside loaders.

For nested routes, child routes can extend the parent context via beforeLoad. We use this for tenant-scoped routes: the parent /_authenticated/$tenantSlug route resolves the tenant and adds it to the context, then all child routes consume context.tenant with full types.

// src/router.tsx
import { createRouter } from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';

export interface RouterContext {
  queryClient: QueryClient;
  auth: AuthService;
  featureFlags: FeatureFlagService;
}

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30_000, gcTime: 5 * 60_000 },
  },
});

export const router = createRouter({
  routeTree,
  context: {
    queryClient,
    auth: new AuthService(),
    featureFlags: new FeatureFlagService(),
  } satisfies RouterContext,
  defaultPreload: 'intent',
  defaultPreloadStaleTime: 0,
  defaultErrorComponent: ({ error }) => ,
});

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

Migrating from React Router 7

The migration from React Router 7 took our team six weeks for the 280-route app. Initially, we ran both routers in parallel using a feature flag at the layout level, which let us migrate route trees incrementally without a big-bang cutover. As routes moved over, we deleted the React Router equivalents and reaped the type-safety benefits immediately.

The hardest part was untangling our search-param utilities, which had grown organically over four years. However, once schemas were in place, downstream code simplified substantially. Refer to the official TanStack Router migration guide for canonical patterns.

Bundle Size and Performance Trade-Offs

TanStack Router adds roughly 32KB gzipped to your bundle, compared to about 18KB for React Router. That said, the file-based code splitting via the Vite plugin is more aggressive and our actual initial bundle dropped by 80KB after migration. The router itself is bigger, but the routes ship lazily.

For very small applications (under 10 routes), React Router or even hand-rolled routing remains lighter. For anything above 30 routes with shared layouts and search-param-heavy pages, TanStack Router pays for itself in both runtime weight and developer hours saved.

bundle analysis for TanStack Router migration
Aggressive route-level code splitting offsets the larger router runtime.

In conclusion, TanStack Router type-safe routing is the routing solution I would have begged for five years ago. The combination of file-based routes, validated search params, integrated loaders, and end-to-end type inference eliminates an entire category of bugs that React developers have tolerated for too long. After six months in production, our routing-related incidents dropped to zero, and team velocity on navigation features roughly doubled.

← Back to all articles