tenequm

tanstack-router

Build type-safe React applications with TanStack Router. Use when implementing file-based or code-based routing, type-safe navigation, search params validation, data loading, code splitting, or route-level error handling. Triggers on "tanstack router", "file-based routing", "type-safe routing", "search params", "route loader", or file patterns like routes/*.tsx, __root.tsx, routeTree.gen.ts.

tenequm 29 1 Updated 3mo ago

Resources

1
GitHub

Install

npx skillscat add tenequm/claude-plugins/tanstack-router

Install via the SkillsCat registry.

SKILL.md

TanStack Router v1

A fully type-safe router for React with first-class search param APIs, built-in data loading with SWR caching, file-based route generation, and 100% inferred TypeScript support.

When to Use This Skill

  • Setting up file-based or code-based routing in a React application
  • Building type-safe navigation with Link, useNavigate, or router.navigate
  • Validating and managing URL search params as typed state
  • Loading data in route loaders with SWR caching
  • Code splitting routes for optimal bundle size
  • Handling not-found errors and error boundaries per route
  • Implementing route context for dependency injection
  • Configuring preloading strategies (intent, viewport, render)
  • Integrating TanStack Query with route loaders
  • Adding navigation blocking for unsaved changes
  • Building SSR applications (for full SSR, see the tanstack-start skill)

Quick Start Workflow

1. Install and Configure (Vite)

npm install @tanstack/react-router @tanstack/router-plugin
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    tanstackRouter({ autoCodeSplitting: true }),
    react(),
  ],
})

2. Create Routes

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

export const Route = createRootRoute({
  component: () => (
    <>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      <Outlet />
    </>
  ),
})
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: () => <div>Welcome Home</div>,
})

3. Create and Register the Router

// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
// src/main.tsx
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'

function App() {
  return <RouterProvider router={router} />
}

File-Based Routing

Files in src/routes/ are automatically converted to route configuration by the Vite plugin or CLI.

Naming Conventions

Convention Purpose Example
__root.tsx Root route (always rendered) src/routes/__root.tsx
index.tsx Index route for parent path src/routes/index.tsx matches /
. separator Nested route (flat files) posts.tsx = /posts
$param Dynamic path parameter posts.$postId.tsx = /posts/:postId
_ prefix Pathless layout route _layout.tsx wraps children, no URL segment
_ suffix Non-nested route posts_.edit.tsx breaks out of posts nesting
- prefix Excluded from routing -components/Button.tsx for colocated files
(folder) Route group (no URL segment) (auth)/login.tsx = /login
.lazy.tsx Lazy-loaded component posts.lazy.tsx for code-split components
.route.tsx Directory route file posts/route.tsx instead of posts.tsx

Flat (posts.$postId.tsx) and directory (posts/$postId.tsx) structures can be mixed freely.

Type-Safe Navigation

All navigation APIs share to, from, params, search, and hash options.

Link Component

import { Link } from '@tanstack/react-router'

<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>

// Relative navigation
<Link from="/posts/$postId" to="..">Back to Posts</Link>

// Active styling
<Link to="/posts" activeProps={{ className: 'font-bold' }} activeOptions={{ exact: true }}>
  Posts
</Link>

useNavigate Hook

For imperative navigation from side effects:

const navigate = useNavigate({ from: '/posts' })

const handleSubmit = async (data: PostInput) => {
  const post = await createPost(data)
  navigate({ to: '/posts/$postId', params: { postId: post.id } })
}

linkOptions Helper

Reusable type-safe link configuration:

import { linkOptions } from '@tanstack/react-router'

const postLink = linkOptions({ to: '/posts/$postId', params: { postId: '123' } })
<Link {...postLink}>View Post</Link>

Always provide from on Link and hooks to narrow types and improve TS performance. Without from, TypeScript must check against all routes.

Search Params

Search params are first-class - validated, typed, JSON-serialized, and subscribable with fine-grained selectors.

Validation with Zod

import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'

const productSearchSchema = z.object({
  page: fallback(z.number(), 1).default(1),
  filter: fallback(z.string(), '').default(''),
  sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default('newest'),
})

export const Route = createFileRoute('/shop/products')({
  validateSearch: zodValidator(productSearchSchema),
})

Use fallback(...).default(...) from the Zod adapter to retain types. Plain .catch() causes type loss. Valibot and ArkType work without adapters via Standard Schema support.

Reading and Writing

// Reading (type-safe)
const { page, sort } = Route.useSearch()
// From code-split component (avoids circular imports)
const search = getRouteApi('/shop/products').useSearch()
// Loose typing for shared components
const search = useSearch({ strict: false })

// Writing via Link
<Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>Next</Link>
// Writing via useNavigate
const navigate = useNavigate({ from: Route.fullPath })
navigate({ search: (prev) => ({ ...prev, page: 2 }) })

Search Middlewares

import { retainSearchParams, stripSearchParams } from '@tanstack/react-router'

export const Route = createFileRoute('/shop/products')({
  validateSearch: zodValidator(productSearchSchema),
  search: {
    middlewares: [
      retainSearchParams(['globalFilter']),
      stripSearchParams({ sort: 'newest' }),
    ],
  },
})

Data Loading

Route loaders run in parallel before rendering with built-in SWR caching.

Basic Loader

export const Route = createFileRoute('/posts')({
  loader: () => fetchPosts(),
  component: () => {
    const posts = Route.useLoaderData()
    return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
  },
})

loaderDeps - Search Params as Cache Keys

export const Route = createFileRoute('/posts')({
  validateSearch: z.object({ page: z.number().catch(1), limit: z.number().catch(10) }),
  loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
  loader: ({ deps: { page, limit } }) => fetchPosts({ page, limit }),
})

Only include deps you actually use - returning the entire search object causes unnecessary cache invalidation.

Caching and Staleness

  • staleTime - How long data is fresh (default: 0 for navigation, 30s for preload)
  • gcTime - How long unused data stays in cache (default: 30 minutes)
  • shouldReload - Custom reload logic beyond staleTime

beforeLoad - Guards and Context

Runs serially before loaders. Use for auth redirects or injecting route-specific context:

export const Route = createFileRoute('/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login', search: { redirect: '/dashboard' } })
    }
    return { user: context.auth.user }
  },
  loader: ({ context: { user } }) => fetchDashboard(user.id),
})

Pending Components

export const Route = createFileRoute('/posts')({
  loader: () => fetchPosts(),
  pendingComponent: () => <Spinner />,
  pendingMs: 1000,     // Wait before showing (default: 1000)
  pendingMinMs: 500,   // Minimum display time to avoid flash (default: 500)
})

Route Context

Hierarchical dependency injection via createRootRouteWithContext. Context merges down the tree and is fully type-safe.

// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'

interface RouterContext { queryClient: QueryClient }

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootComponent,
})

// src/router.ts - context is required by the type
const router = createRouter({ routeTree, context: { queryClient } })

// Child routes access context in loaders
export const Route = createFileRoute('/posts')({
  loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions()),
})

Pass React hooks/state at runtime via RouterProvider:

function App() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

Error Handling

errorComponent

export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params }) => fetchPost(params.postId),
  errorComponent: ({ error }) => {
    const router = useRouter()
    return (
      <div>
        <p>{error.message}</p>
        <button onClick={() => router.invalidate()}>Retry</button>
      </div>
    )
  },
})

notFoundComponent

Two modes via notFoundMode on the router (default: 'fuzzy'):

  • fuzzy - nearest parent route with children and a notFoundComponent handles it
  • root - root route always handles it
import { notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId)
    if (!post) throw notFound()
    return { post }
  },
  notFoundComponent: () => <p>Post not found</p>,
})

Set defaultNotFoundComponent on the router for app-wide fallback.

Code Splitting

Automatic (recommended): Enable autoCodeSplitting: true in the Vite plugin. Non-critical config (component, errorComponent, pendingComponent, notFoundComponent) is split into separate chunks automatically.

Manual with .lazy.tsx: Split into two files - critical config in posts.tsx (loader, validateSearch), non-critical in posts.lazy.tsx (component via createLazyFileRoute).

The root route (__root.tsx) does not support code splitting since it always renders.

Preloading

const router = createRouter({
  routeTree,
  defaultPreload: 'intent',   // Preload on hover/touch
  defaultPreloadDelay: 50,     // ms delay (default: 50)
})

Strategies: 'intent' (hover/touch), 'viewport' (Intersection Observer), 'render' (on mount). Override per-link with preload prop. Manual: router.preloadRoute({ to, params }).

TanStack Query Integration

const postQueryOptions = (postId: string) =>
  queryOptions({ queryKey: ['post', postId], queryFn: () => fetchPost(postId) })

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ context: { queryClient }, params: { postId } }) => {
    await queryClient.ensureQueryData(postQueryOptions(postId))
  },
  component: () => {
    const { postId } = Route.useParams()
    const { data } = useSuspenseQuery(postQueryOptions(postId))
    return <div>{data.title}</div>
  },
})

Set defaultPreloadStaleTime: 0 on the router when using external caching so loaders always fire.

Advanced Topics

See reference files for deep dives:

  • references/search-params.md - Custom serialization, Standard Schema validation, arrays/objects, sharing across routes
  • references/data-loading.md - Deferred data loading with Await, external data loading, shouldReload, streaming SSR
  • references/routing-patterns.md - Virtual file routes, route masking, navigation blocking, authenticated routes, parallel routes
  • references/code-splitting.md - Automatic splitting options, loader splitting, directory encapsulation, code-based splitting
  • references/ssr.md - SSR setup, streaming, dehydration/hydration, data serialization, TanStack Start integration

DevTools

npm install @tanstack/react-router-devtools
// src/routes/__root.tsx
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  ),
})

Automatically excluded from production. Use TanStackRouterDevtoolsInProd if needed in prod.

Best Practices

  1. Use file-based routing with autoCodeSplitting - Generates the route tree and optimizes bundles. Fall back to code-based only when you need programmatic control.
  2. Always validate search params - Use validateSearch with Zod (via zodValidator) or any Standard Schema library. Use fallback(...).default(...) to retain types.
  3. Provide from on navigation hooks and components - Narrows types, improves TS performance, catches route mismatches at runtime.
  4. Extract only needed deps in loaderDeps - Return only params your loader uses, not the full search object.
  5. Use route context for dependency injection - Pass QueryClient, auth, or services via createRootRouteWithContext instead of importing singletons.
  6. Set preload to 'intent' globally - Dramatically improves perceived performance with minimal effort.
  7. Use router.invalidate() in error components - Reloads data and resets the error boundary together.

Resources