Claude Skill

tanstack

Builds type-safe React apps with TanStack Query (data fetching, caching, mutations), Router (file-based routing, search params, loaders), and Start (SSR, server functions, middleware). Use when working with react-query, server state, file-based routing, typed search params, route

LLM Mart · 0 points · 20 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download tenequm-skills-skills_tanstack-1ff2284.zip · 112 KB
Part of tenequm/skills — 25 skills

Install

skills CLI npx skills add https://github.com/tenequm/skills/tree/main/skills/tanstack
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
Git git clone https://github.com/tenequm/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

TanStack (Query + Router + Start)

Type-safe libraries for React applications. Query manages server state (fetching, caching, mutations). Router provides file-based routing with validated search params and data loaders. Start extends Router with SSR, server functions, and middleware for full-stack apps.

When to Use

Query - data fetching, caching, mutations, optimistic updates, infinite scroll, streaming AI/SSE responses, tRPC v11 integration Router - file-based routing, type-safe navigation, validated search params, route loaders, code splitting, preloading Start - SSR/SSG, server functions (type-safe RPCs), middleware, API routes, deployment to Cloudflare/Vercel/Node

Decision tree:

  • Client-only SPA with API calls -> Router + Query
  • Full-stack with SSR/server functions -> Start + Query (Start includes Router)

TanStack Query v5

Setup

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}

Queries

import { useQuery, queryOptions } from '@tanstack/react-query'

// Reusable query definition (recommended pattern)
const todosQueryOptions = queryOptions({
  queryKey: ['todos'],
  queryFn: async () => {
    const res = await fetch('/api/todos')
    if (!res.ok) throw new Error('Failed to fetch')
    return res.json() as Promise<Todo[]>
  },
})

// In component - full type inference from queryOptions
function TodoList() {
  const { data, isLoading, error } = useQuery(todosQueryOptions)
  if (isLoading) return <Spinner />
  if (error) return <div>Error: {error.message}</div>
  return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}

Mutations

import { useMutation, useQueryClient } from '@tanstack/react-query'

function CreateTodo() {
  const queryClient = useQueryClient()
  const mutation = useMutation({
    mutationFn: (newTodo: { title: string }) =>
      fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then(r => r.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <button onClick={() => mutation.mutate({ title: 'New' })}>
      {mutation.isPending ? 'Creating...' : 'Create'}
    </button>
  )
}

Key Patterns

Query keys - hierarchical arrays for cache management:

['todos']                          // all todos
['todos', 'list', { page, sort }]  // filtered list
['todo', todoId]                   // single item

Dependent queries - chain with enabled:

const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => fetchProjects(user!.id),
  enabled: !!user?.id,
})

Important defaults: staleTime: 0, gcTime: 5min, retry: 3, refetchOnWindowFocus: true

Suspense - use useSuspenseQuery with <Suspense> boundaries

Streamed queries (experimental) - for AI chat/SSE:

import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'

const { data: chunks } = useQuery(queryOptions({
  queryKey: ['chat', sessionId],
  queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), refetchMode: 'reset' }),
}))

DevTools

pnpm add @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// Add inside QueryClientProvider
<ReactQueryDevtools initialIsOpen={false} />

Query Deep Dives

  • query-guide.md - Complete Query reference with all patterns
  • infinite-queries.md - useInfiniteQuery, pagination, virtual scroll
  • optimistic-updates.md - Optimistic UI, rollback, undo
  • query-performance.md - staleTime tuning, deduplication, prefetching
  • query-invalidation.md - Cache invalidation strategies, filters, predicates
  • query-typescript.md - Type inference, generics, custom hooks

TanStack Router v1

Setup (Vite)

pnpm add @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(),
  ],
})
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({ routeTree, defaultPreload: 'intent' })

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

File-Based Routing

Files in src/routes/ auto-generate route config:

Convention Purpose Example
__root.tsx Root route (always rendered) src/routes/__root.tsx
index.tsx Index route src/routes/index.tsx -> /
$param Dynamic segment posts.$postId.tsx -> /posts/:id
_prefix Pathless layout _layout.tsx wraps children
(folder) Route group (no URL) (auth)/login.tsx -> /login

Type-Safe Navigation

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

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

// Imperative
const navigate = useNavigate({ from: '/posts' })
navigate({ to: '/posts/$postId', params: { postId: post.id } })

Always provide from on Link and hooks - narrows types and improves TS performance.

Search Params

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

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

export const Route = createFileRoute('/products')({
  validateSearch: zodValidator(searchSchema),
  component: () => {
    const { page, sort } = Route.useSearch()
    // Writing
    return <Link from={Route.fullPath} search={prev => ({ ...prev, page: prev.page + 1 })}>Next</Link>
  },
})

Use fallback(...).default(...) from the Zod adapter (Zod v3); plain .catch() causes type loss. With Zod v4 the adapter is no longer needed - pass the schema directly to validateSearch, and .catch() retains type inference.

Data Loading

export const Route = createFileRoute('/posts')({
  // loaderDeps: only extract what loader needs (not full search)
  loaderDeps: ({ search: { page } }) => ({ page }),
  loader: ({ deps: { page } }) => fetchPosts({ page }),
  pendingComponent: () => <Spinner />,
  component: () => {
    const posts = Route.useLoaderData()
    return <PostList posts={posts} />
  },
})

Route Context (Dependency Injection)

// __root.tsx
interface RouterContext { queryClient: QueryClient }
export const Route = createRootRouteWithContext<RouterContext>()({ component: Root })

// router.ts
const router = createRouter({ routeTree, context: { queryClient } })

// Child route - queryClient available in loader
export const Route = createFileRoute('/posts')({
  loader: ({ context: { queryClient } }) =>
    queryClient.ensureQueryData(postsQueryOptions()),
})

Router Deep Dives

  • router-guide.md - Complete Router reference with all patterns
  • search-params.md - Custom serialization, Standard Schema, sharing params
  • data-loading.md - Deferred loading, streaming SSR, shouldReload
  • routing-patterns.md - Virtual routes, route masking, navigation blocking
  • code-splitting.md - Automatic/manual splitting strategies
  • router-ssr.md - SSR setup, streaming, hydration

TanStack Start

Full-stack framework extending Router with SSR, server functions, middleware. Pre-1.0 (API stable, feature-complete, preparing for 1.0). React Server Components are available as an experimental feature - opt in with tanstackStart({ rsc: { enabled: true } }) + @vitejs/plugin-rsc (requires React 19, Vite 7+). Vite is the default bundler; Rsbuild is also supported.

Setup

npx @tanstack/cli@latest create   # or use TanStack Builder: https://tanstack.com/builder
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    tanstackStart(),
    viteReact(), // MUST come after tanstackStart()
  ],
})

Server Functions

Type-safe RPCs. Server code extracted from client bundles at build time.

import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

// GET - no input
export const getUsers = createServerFn({ method: 'GET' })
  .handler(async () => db.users.findMany())

// POST - validated input
export const createUser = createServerFn({ method: 'POST' })
  .validator(z.object({ name: z.string(), email: z.string().email() }))
  .handler(async ({ data }) => db.users.create(data))

// Call from loader
export const Route = createFileRoute('/users')({
  loader: () => getUsers(),
  component: () => {
    const users = Route.useLoaderData()
    return <UserList users={users} />
  },
})

Critical: Loaders are isomorphic (run on server AND client). Never put secrets in loaders - use createServerFn() instead.

Middleware

import { createMiddleware } from '@tanstack/react-start'

const authMiddleware = createMiddleware({ type: 'function' })
  .server(async ({ next }) => {
    const user = await getCurrentUser()
    if (!user) throw redirect({ to: '/login' })
    return next({ context: { user } })
  })

const getProfile = createServerFn()
  .middleware([authMiddleware])
  .handler(async ({ context }) => context.user) // typed

Global middleware via src/start.ts:

export const startInstance = createStart(() => ({
  requestMiddleware: [logger],    // all requests
  functionMiddleware: [auth],     // all server functions
}))

CSRF: Start auto-installs createCsrfMiddleware() for server functions only when there is no src/start.ts. Once you create src/start.ts, add it back explicitly, or non-GET server functions lose same-origin protection:

requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), logger]

SSR Modes

Mode Use Case
true (default) SEO, performance
false Browser-only features
'data-only' Dashboards (data on server, render on client)

SPA mode: tanstackStart({ spa: { enabled: true } }) in vite.config.ts

Deployment

  • Official partners: Cloudflare Workers (@cloudflare/vite-plugin), Netlify (@netlify/vite-plugin-tanstack-start), Railway
  • Node/Vercel/Bun/Docker: via Nitro
  • Static: tanstackStart({ prerender: { enabled: true, crawlLinks: true } })

Start Deep Dives

  • start-guide.md - Complete Start reference with all patterns
  • server-functions.md - Streaming, FormData, progressive enhancement
  • middleware.md - sendContext, custom fetch, global config
  • ssr-modes.md - Selective SSR, shellComponent, fallback rendering
  • server-routes.md - Dynamic params, wildcards, pathless layouts

Best Practices

  1. Use queryOptions() factory for reusable, type-safe query definitions
  2. Structure query keys hierarchically - ['entity', 'action', { filters }]
  3. Set staleTime per data type - static: Infinity, dynamic: 0, moderate: 5min
  4. Always validate search params with Zod via zodValidator + fallback().default()
  5. Provide from on navigation - narrows types, catches route mismatches
  6. Use route context for DI - pass QueryClient, auth via createRootRouteWithContext
  7. Set defaultPreload: 'intent' globally for perceived performance
  8. Enforce auth at the data boundary - authorize inside the server function, server route, or API endpoint that reads/writes private data; beforeLoad/route guards are UX, not the security boundary. Never put secrets in isomorphic loaders - use createServerFn()
  9. Compose middleware hierarchically - global -> route -> function
  10. Use head() on every content route for SEO (title, description, OG tags)

Resources

Files (skills)
  • references
    • code-splitting.md 15.6 KB
      # Code Splitting and Preloading
      
      Optimize TanStack Router applications with code splitting, lazy loading, and preloading for faster initial loads and seamless navigation.
      
      Official docs:
      - https://tanstack.com/router/latest/docs/framework/react/guide/code-splitting
      - https://tanstack.com/router/latest/docs/framework/react/guide/automatic-code-splitting
      - https://tanstack.com/router/latest/docs/framework/react/guide/preloading
      - https://tanstack.com/router/latest/docs/framework/react/guide/render-optimizations
      
      ## Critical vs Non-Critical Route Configuration
      
      TanStack Router separates route configuration into two categories.
      
      **Critical (always in the main bundle)** - required to match the route and start data loading:
      
      - Path parsing and serialization
      - Search parameter validation
      - Loaders (`loader`) and before load hooks (`beforeLoad`)
      - Route context, static data, styles, scripts, links
      
      **Non-Critical (can be lazy-loaded)** - not required to match the route:
      
      - `component` - the route component
      - `errorComponent` - rendered when a loader or component throws
      - `pendingComponent` - rendered while the route is loading
      - `notFoundComponent` - rendered when a not-found error is thrown
      
      **Why the loader stays in the main bundle:** The loader is already an async boundary, so splitting it adds a second async hop (fetch the chunk, then execute the loader). Loaders are typically smaller than components and are critical for preloading on hover/touch - they must be available without additional async overhead.
      
      ## Automatic Code Splitting
      
      Enable it in the bundler plugin and TanStack Router handles everything. Only works with file-based routing and a supported bundler plugin (`@tanstack/router-plugin`), not with the standalone CLI or code-based routing.
      
      ```ts
      // 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(), // Must come AFTER the TanStack Router plugin
        ],
      })
      ```
      
      The plugin transforms each route file at build time into a **reference file** (original file rewritten with lazy-loading wrappers) and **virtual files** (minimal files containing only the code for a single property like `component`).
      
      ### Default Split Groupings
      
      Three separate lazy-loaded chunks are created per route by default:
      
      ```ts
      [['component'], ['errorComponent'], ['notFoundComponent']]
      ```
      
      The `pendingComponent` and `loader` remain in the main bundle.
      
      ### Customizing Split Groupings
      
      **Global default behavior** - bundle all UI components into one chunk:
      
      ```ts
      // vite.config.ts
      tanstackRouter({
        autoCodeSplitting: true,
        codeSplittingOptions: {
          defaultBehavior: [
            ['component', 'pendingComponent', 'errorComponent', 'notFoundComponent'],
          ],
        },
      })
      ```
      
      **Programmatic per-route-id control** with `splitBehavior`:
      
      ```ts
      tanstackRouter({
        autoCodeSplitting: true,
        codeSplittingOptions: {
          splitBehavior: ({ routeId }) => {
            if (routeId.startsWith('/admin')) {
              return [['loader', 'component']]
            }
            // All other routes use the defaultBehavior
          },
        },
      })
      ```
      
      **Per-route override** with `codeSplitGroupings` inside the route file:
      
      ```tsx
      // src/routes/posts.tsx
      export const Route = createFileRoute('/posts')({
        codeSplitGroupings: [['loader', 'component']],
        loader: () => fetchPosts(),
        component: PostsComponent,
      })
      
      function PostsComponent() {
        const posts = Route.useLoaderData()
        return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      }
      ```
      
      **Precedence order:** Per-route `codeSplitGroupings` (highest) > `splitBehavior` function > `defaultBehavior` (lowest).
      
      ### Splitting the Data Loader (Automatic)
      
      Introduces an extra network round trip. Only recommended when a route's loader is unusually large:
      
      ```ts
      tanstackRouter({
        autoCodeSplitting: true,
        codeSplittingOptions: {
          defaultBehavior: [['loader'], ['component'], ['errorComponent'], ['notFoundComponent']],
        },
      })
      ```
      
      ### Rules for Automatic Code Splitting
      
      Do not export route properties. Exporting prevents the bundler from splitting them:
      
      ```tsx
      export const Route = createFileRoute('/posts')({ component: PostsComponent })
      
      // BAD - exporting prevents code splitting
      export function PostsComponent() { return <div>Posts</div> }
      
      // GOOD - keep it as a local function
      function PostsComponent() { return <div>Posts</div> }
      ```
      
      ## Manual Code Splitting with .lazy.tsx
      
      Use the `.lazy.tsx` convention when you cannot use automatic code splitting. The root route (`__root.tsx`) does not support code splitting.
      
      Split a route into two files - main for critical config, `.lazy.tsx` for non-critical:
      
      ```tsx
      // src/routes/posts.tsx - critical configuration
      import { createFileRoute } from '@tanstack/react-router'
      import { fetchPosts } from '../api'
      
      export const Route = createFileRoute('/posts')({
        loader: fetchPosts,
      })
      ```
      
      ```tsx
      // src/routes/posts.lazy.tsx - non-critical (lazy-loaded)
      import { createLazyFileRoute } from '@tanstack/react-router'
      
      export const Route = createLazyFileRoute('/posts')({
        component: Posts,
      })
      
      function Posts() {
        const posts = Route.useLoaderData()
        return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      }
      ```
      
      `createLazyFileRoute` only supports: `component`, `errorComponent`, `pendingComponent`, `notFoundComponent`. Everything else must stay in the main route file.
      
      ### Multiple Non-Critical Properties
      
      ```tsx
      // src/routes/dashboard.lazy.tsx
      import { createLazyFileRoute } from '@tanstack/react-router'
      
      export const Route = createLazyFileRoute('/dashboard')({
        component: Dashboard,
        errorComponent: DashboardError,
        pendingComponent: () => <div>Loading dashboard...</div>,
        notFoundComponent: () => <div>Dashboard not found</div>,
      })
      
      function Dashboard() { return <div>Dashboard content</div> }
      function DashboardError({ error }: { error: Error }) { return <div>Error: {error.message}</div> }
      ```
      
      ### Virtual Routes
      
      If a main route file becomes empty, delete it. TanStack Router generates a virtual route in the route tree:
      
      ```tsx
      // src/routes/about.lazy.tsx (no src/routes/about.tsx needed)
      import { createLazyFileRoute } from '@tanstack/react-router'
      
      export const Route = createLazyFileRoute('/about')({
        component: () => <div>About page</div>,
      })
      ```
      
      ### Directory Encapsulation
      
      Move route files into a directory for organization. Behavior is identical:
      
      ```
      src/routes/posts/route.tsx       # was posts.tsx
      src/routes/posts/route.lazy.tsx  # was posts.lazy.tsx
      ```
      
      ## Code-Based Route Splitting
      
      For code-based routing, use `createLazyRoute` and the `.lazy()` method:
      
      ```tsx
      // src/posts.lazy.tsx
      import { createLazyRoute } from '@tanstack/react-router'
      
      export const Route = createLazyRoute('/posts')({
        component: () => <div>Posts</div>,
      })
      ```
      
      ```tsx
      // src/app.tsx
      const postsRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: '/posts',
        loader: () => fetchPosts(),
      }).lazy(() => import('./posts.lazy').then((d) => d.Route))
      ```
      
      **Manual loader splitting** with `lazyFn`:
      
      ```tsx
      import { createRoute, lazyFn } from '@tanstack/react-router'
      
      const postsRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: '/posts',
        loader: lazyFn(() => import('./posts-loader'), 'loader'),
      })
      ```
      
      ```tsx
      // src/posts-loader.ts
      import type { LoaderContext } from '@tanstack/react-router'
      
      export const loader = async (context: LoaderContext) => {
        const response = await fetch('/api/posts')
        return response.json()
      }
      ```
      
      ## Accessing Route APIs from Split Files
      
      Use `getRouteApi` for type-safe access to route hooks without importing the route:
      
      ```tsx
      import { getRouteApi } from '@tanstack/react-router'
      
      const postsRoute = getRouteApi('/posts')
      
      export function PostsPage() {
        const loaderData = postsRoute.useLoaderData()
        const search = postsRoute.useSearch()
        const params = postsRoute.useParams()
        return <div>{/* render */}</div>
      }
      ```
      
      Available hooks: `useLoaderData`, `useSearch`, `useParams`, `useRouteContext`, `useMatch`, `useLoaderDeps`.
      
      ## Preloading Strategies
      
      Preloading loads a route's code and data before the user navigates to it.
      
      **Intent** (recommended default) - preloads on hover/touch of a `<Link>`:
      
      ```tsx
      const router = createRouter({ routeTree, defaultPreload: 'intent' })
      ```
      
      **Viewport** - uses Intersection Observer to preload when a `<Link>` scrolls into view:
      
      ```tsx
      <Link to="/posts" preload="viewport">Posts</Link>
      ```
      
      **Render** - preloads as soon as the `<Link>` mounts in the DOM:
      
      ```tsx
      <Link to="/settings" preload="render">Settings</Link>
      ```
      
      ## Preload Configuration
      
      ### Router-Level Defaults
      
      ```tsx
      const router = createRouter({
        routeTree,
        defaultPreload: 'intent',           // 'intent' | 'viewport' | 'render' | false
        defaultPreloadDelay: 50,             // ms before preloading starts (default: 50)
        defaultPreloadStaleTime: 30_000,     // ms before preloaded data is re-fetched (default: 30_000)
        defaultPreloadMaxAge: 30_000,        // ms before unused preloaded data is removed (default: 30_000)
      })
      ```
      
      ### Per-Link Overrides
      
      ```tsx
      <Link to="/posts/$postId" params={{ postId: post.id }} preload="intent" preloadDelay={100}>
        {post.title}
      </Link>
      ```
      
      ### Per-Route Stale Time
      
      ```tsx
      // src/routes/posts.$postId.tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => fetchPost(params.postId),
        preloadStaleTime: 10_000, // Re-preload if older than 10s
      })
      ```
      
      ### Disabling Preloading
      
      ```tsx
      <Link to="/expensive-route" preload={false}>Expensive Route</Link>
      ```
      
      ### Preloading with External Libraries
      
      When using TanStack Query alongside TanStack Router, set `defaultPreloadStaleTime: 0` so React Query manages cache freshness:
      
      ```tsx
      const router = createRouter({
        routeTree,
        defaultPreload: 'intent',
        defaultPreloadStaleTime: 0, // Let React Query handle stale/fresh decisions
      })
      ```
      
      ## Manual Preloading
      
      **`router.preloadRoute()`** - preload a route's code and data programmatically:
      
      ```tsx
      function Component() {
        const router = useRouter()
      
        useEffect(() => {
          router.preloadRoute({
            to: '/posts/$postId',
            params: { postId: '1' },
          }).catch(() => { /* preload is best-effort */ })
        }, [router])
      
        return <div />
      }
      ```
      
      **`router.loadRouteChunk()`** - load only the JS chunk without executing the loader:
      
      ```tsx
      function AppShell() {
        const router = useRouter()
      
        useEffect(() => {
          Promise.all([
            router.loadRouteChunk(router.routesByPath['/dashboard']),
            router.loadRouteChunk(router.routesByPath['/settings']),
          ]).catch(() => {})
        }, [router])
      
        return <div>{/* app content */}</div>
      }
      ```
      
      ## Render Optimizations
      
      ### Structural Sharing
      
      TanStack Router preserves referential identity for unchanged parts of URL-derived state. Navigating from `/details?foo=f1&bar=b1` to `/details?foo=f1&bar=b2`:
      
      - `search.foo` retains the same reference (referentially stable)
      - Only `search.bar` is replaced
      
      ### Fine-Grained Selectors with select
      
      Subscribe to a specific subset so the component only re-renders when that subset changes:
      
      ```tsx
      function DetailsPage() {
        // Only re-renders when foo changes, not when bar changes
        const foo = Route.useSearch({ select: (search) => search.foo })
        return <div>Foo: {foo}</div>
      }
      ```
      
      ### Structural Sharing with Selectors
      
      When `select` returns a new object, the component re-renders every time. Enable structural sharing to preserve referential stability:
      
      ```tsx
      // Enable globally
      const router = createRouter({ routeTree, defaultStructuralSharing: true })
      
      // Or per hook
      const result = Route.useSearch({
        select: (search) => ({ foo: search.foo, greeting: `hello ${search.foo}` }),
        structuralSharing: true,
      })
      ```
      
      **Constraint:** Structural sharing only works with JSON-compatible data. Class instances, Dates, Maps, and Sets are not supported. TypeScript will error if you return non-JSON values with `structuralSharing: true`. Disable it per hook with `structuralSharing: false`.
      
      ## Common Patterns
      
      ### Lazy-Loading a Heavy Component
      
      Keep the route lean, push heavy dependencies into the lazy file:
      
      ```tsx
      // src/routes/analytics.tsx
      export const Route = createFileRoute('/analytics')({
        validateSearch: (s: Record<string, unknown>) => ({ range: (s.range as string) || '7d' }),
        loader: ({ context }) => context.api.fetchAnalytics(),
      })
      ```
      
      ```tsx
      // src/routes/analytics.lazy.tsx
      import { createLazyFileRoute } from '@tanstack/react-router'
      import { BarChart, LineChart, PieChart } from 'recharts' // Heavy - only loaded when needed
      
      export const Route = createLazyFileRoute('/analytics')({
        component: AnalyticsDashboard,
        pendingComponent: () => <div>Loading analytics...</div>,
      })
      
      function AnalyticsDashboard() {
        const data = Route.useLoaderData()
        return (
          <div>
            <LineChart data={data.revenue} />
            <BarChart data={data.signups} />
            <PieChart data={data.sources} />
          </div>
        )
      }
      ```
      
      ### Preloading on Hover for List-to-Detail Navigation
      
      ```tsx
      function PostCard({ post }: { post: Post }) {
        return (
          <Link to="/posts/$postId" params={{ postId: post.id }} preload="intent" preloadDelay={75}>
            <h3>{post.title}</h3>
            <p>{post.excerpt}</p>
          </Link>
        )
      }
      ```
      
      ### Prefetching the Next Page in Pagination
      
      ```tsx
      export const Route = createFileRoute('/items')({
        validateSearch: (s: Record<string, unknown>) => ({ page: Number(s.page) || 1 }),
        loaderDeps: ({ search }) => ({ page: search.page }),
        loader: async ({ deps }) => fetchItems(deps.page),
        component: ItemsList,
      })
      
      function ItemsList() {
        const router = useRouter()
        const items = Route.useLoaderData()
        const { page } = Route.useSearch()
      
        useEffect(() => {
          if (items.hasNextPage) {
            router.preloadRoute({ to: '/items', search: { page: page + 1 } })
          }
        }, [router, page, items.hasNextPage])
      
        return (
          <div>
            <ul>{items.data.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
            <Link to="/items" search={{ page: page - 1 }} disabled={page === 1}>Previous</Link>
            <Link to="/items" search={{ page: page + 1 }} disabled={!items.hasNextPage}>Next</Link>
          </div>
        )
      }
      ```
      
      ### Preloading Critical Routes on App Startup
      
      ```tsx
      function AppLayout() {
        const router = useRouter()
      
        useEffect(() => {
          Promise.all([
            router.loadRouteChunk(router.routesByPath['/dashboard']),
            router.loadRouteChunk(router.routesByPath['/settings']),
          ]).catch(() => {})
        }, [router])
      
        return <div><Sidebar /><main><Outlet /></main></div>
      }
      ```
      
      ## Best Practices
      
      1. **Start with automatic code splitting.** Set `autoCodeSplitting: true` in the Vite plugin. It handles the common case with zero route file changes. Only reach for manual `.lazy.tsx` splitting if you cannot use the bundler plugin.
      
      2. **Keep loaders in the main bundle.** The double async hop from splitting a loader outweighs bundle size savings in most cases. The loader is critical for preloading performance.
      
      3. **Enable intent preloading globally.** Setting `defaultPreload: 'intent'` is the single highest-impact preloading optimization. It eliminates perceived loading for most click-driven navigations.
      
      4. **Do not export route properties.** Exporting `component`, `errorComponent`, or other split-eligible properties prevents the bundler from extracting them into separate chunks.
      
      5. **Use `getRouteApi` in split files.** Access `useLoaderData`, `useSearch`, and `useParams` from `.lazy.tsx` files without importing the route definition directly.
      
      6. **Set `defaultPreloadStaleTime: 0` with TanStack Query.** Defers cache freshness decisions to React Query's `staleTime`, avoiding double-caching between the router and the query library.
      
      7. **Prefetch the next page in paginated views.** Call `router.preloadRoute()` with the next page's search params on render. Forward pagination becomes instant.
      
    • data-loading.md 22.1 KB
      # Data Loading
      
      TanStack Router provides a built-in data loading system with route loaders, SWR caching, and deep integration points for external libraries like TanStack Query.
      
      Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/data-loading
      
      ## Route Loaders
      
      The `loader` function is the primary mechanism for loading data. It runs before the route component renders and receives a single object parameter.
      
      ### Loader Function Signature
      
      ```tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/posts')({
        loader: async ({
          params,             // Route path params (e.g., { postId: '1' })
          deps,               // Object from loaderDeps, or {} if not defined
          context,            // Merged route context (parent + beforeLoad)
          abortController,    // Cancelled on unload or outdated invocation
          preload,            // true when preloading instead of loading
          cause,              // 'enter' | 'stay' | 'preload'
          location,           // Current location object
          route,              // The route object itself
          parentMatchPromise, // Promise for parent match (undefined for root)
        }) => {
          return await fetchPosts()
        },
      })
      ```
      
      ### Basic Examples
      
      ```tsx
      // Simple data fetch
      export const Route = createFileRoute('/posts')({
        loader: () => fetchPosts(),
      })
      
      // Using path params
      export const Route = createFileRoute('/posts/$postId')({
        loader: ({ params: { postId } }) => fetchPost(postId),
      })
      
      // Using the abort signal
      export const Route = createFileRoute('/posts')({
        loader: ({ abortController }) =>
          fetch('/api/posts', { signal: abortController.signal }).then((r) => r.json()),
      })
      
      // Conditional logic based on preload
      export const Route = createFileRoute('/posts')({
        loader: async ({ preload }) =>
          fetchPosts({ maxAge: preload ? 10_000 : 0 }),
      })
      ```
      
      ## Consuming Loader Data
      
      ### useLoaderData and getRouteApi
      
      ```tsx
      export const Route = createFileRoute('/posts')({
        loader: () => fetchPosts(),
        component: PostsComponent,
      })
      
      function PostsComponent() {
        // Option 1: directly from Route object
        const posts = Route.useLoaderData()
        return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      }
      
      // Option 2: getRouteApi - avoids circular imports in deep components
      import { getRouteApi } from '@tanstack/react-router'
      const routeApi = getRouteApi('/posts')
      
      function PostsList() {
        const posts = routeApi.useLoaderData()
        return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      }
      ```
      
      ### useRouteContext
      
      Access route context (including data injected via `beforeLoad`):
      
      ```tsx
      function PostsComponent() {
        const { user, permissions } = Route.useRouteContext()
      }
      ```
      
      ## Loader Dependencies (loaderDeps)
      
      `loaderDeps` extracts dependencies from search params to serve as cache keys. When deps change between navigations, the loader re-runs regardless of `staleTime`.
      
      Search params are deliberately not available directly in `loader` - this forces explicit dependency declaration which enables correct caching, prevents preloaded data from overwriting the current view, and ensures unique cache entries per set of dependencies.
      
      ```tsx
      import { z } from 'zod'
      
      export const Route = createFileRoute('/posts')({
        validateSearch: z.object({
          page: z.number().int().nonnegative().catch(0),
          limit: z.number().int().positive().catch(10),
        }),
        loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
        loader: ({ deps: { page, limit } }) => fetchPosts({ page, limit }),
      })
      ```
      
      ### Common Mistake - Returning the Entire Search Object
      
      ```tsx
      // BAD - reloads when ANY search param changes, even unused ones
      loaderDeps: ({ search }) => search,
      loader: ({ deps }) => fetchPosts({ page: deps.page }),
      
      // GOOD - only reload when actually-used params change
      loaderDeps: ({ search }) => ({ page: search.page, limit: search.limit }),
      loader: ({ deps }) => fetchPosts(deps),
      ```
      
      Deps are compared using deep equality. Returning extraneous fields causes unnecessary reloads.
      
      ## Cache Configuration
      
      The built-in SWR cache is keyed on the route's pathname and `loaderDeps`. Cached data is returned immediately while potentially being refetched in the background.
      
      ### Key Options
      
      | Option | Route-Level | Router Default | Default Value | Description |
      |--------|------------|----------------|---------------|-------------|
      | `staleTime` | `routeOptions.staleTime` | `defaultStaleTime` | `0` | How long (ms) data is fresh for navigations |
      | `preloadStaleTime` | `routeOptions.preloadStaleTime` | `defaultPreloadStaleTime` | `30_000` | How long (ms) data is fresh for preloads |
      | `gcTime` | `routeOptions.gcTime` | `defaultGcTime` | `1_800_000` (30 min) | How long (ms) unused data stays before GC |
      | `shouldReload` | `routeOptions.shouldReload` | - | `undefined` | Boolean or function controlling reload |
      
      ### Important Defaults
      
      - `staleTime: 0` - data is always refetched in the background on navigation (SWR).
      - `preloadStaleTime: 30_000` - preloaded routes won't re-preload within 30 seconds.
      - `gcTime: 1_800_000` - cached data GC'd after 30 minutes of inactivity.
      - `router.invalidate()` forces all active loaders to reload and marks all cache as stale.
      
      ### Configuring staleTime
      
      ```tsx
      export const Route = createFileRoute('/posts')({
        loader: () => fetchPosts(),
        staleTime: 10_000,      // fresh for 10 seconds
      })
      
      export const Route = createFileRoute('/settings')({
        loader: () => fetchSettings(),
        staleTime: Infinity,    // disable SWR - always fresh once loaded
      })
      
      // Or set a global default
      const router = createRouter({ routeTree, defaultStaleTime: Infinity })
      ```
      
      ### shouldReload and gcTime for Remix-Style Behavior
      
      Load data only on entry or when deps change (no background refetching):
      
      ```tsx
      export const Route = createFileRoute('/posts')({
        loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
        loader: ({ deps }) => fetchPosts(deps),
        gcTime: 0,             // Do not cache after route unmounts
        shouldReload: false,   // Only reload on entry or when deps change
      })
      ```
      
      `shouldReload` can also be a function receiving the same parameters as `loader`:
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        loader: () => fetchDashboardData(),
        shouldReload: ({ cause }) => cause === 'enter',
      })
      ```
      
      ## beforeLoad
      
      Runs before `loader` and all child `beforeLoad` functions. Serial and top-down, making it suitable for auth checks, redirects, and context injection.
      
      ```
      Route Matching (Top-Down, Serial):    1. params.parse  2. validateSearch
      Route Pre-Loading (Serial, Top-Down): 3. beforeLoad
      Route Loading (Parallel):             4. loader  5. component.preload
      ```
      
      If `beforeLoad` throws, none of its child routes will attempt to load.
      
      ### Auth Guard with Redirect
      
      ```tsx
      import { createFileRoute, redirect } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/_authenticated')({
        beforeLoad: async ({ location }) => {
          if (!isAuthenticated()) {
            throw redirect({
              to: '/login',
              search: { redirect: location.href },
            })
          }
        },
      })
      ```
      
      ### Injecting Route Context
      
      Return an object from `beforeLoad` to merge into the route's context for `loader` and child routes:
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        beforeLoad: async ({ context }) => {
          const user = await fetchCurrentUser(context.authToken)
          return { user, permissions: user.permissions }
        },
        loader: ({ context: { user, permissions } }) =>
          fetchDashboardData(user.id, permissions),
      })
      ```
      
      ### Auth Check with Error Handling
      
      Use `isRedirect()` to distinguish intentional redirects from actual errors in try/catch blocks:
      
      ```tsx
      import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/_authenticated')({
        beforeLoad: async ({ location }) => {
          try {
            const user = await verifySession()
            if (!user) throw redirect({ to: '/login', search: { redirect: location.href } })
            return { user }
          } catch (error) {
            if (isRedirect(error)) throw error     // re-throw intentional redirects
            throw redirect({ to: '/login', search: { redirect: location.href } })
          }
        },
      })
      ```
      
      ### Router-Level Context
      
      ```tsx
      // routes/__root.tsx
      import { createRootRouteWithContext } from '@tanstack/react-router'
      
      interface RouterContext {
        queryClient: QueryClient
        auth: AuthState
      }
      
      export const Route = createRootRouteWithContext<RouterContext>()({
        component: RootComponent,
      })
      
      // router.tsx
      const router = createRouter({
        routeTree,
        context: { queryClient, auth: undefined! },
      })
      
      // App.tsx - pass dynamic context
      function InnerApp() {
        const auth = useAuth()
        return <RouterProvider router={router} context={{ auth }} />
      }
      ```
      
      ## Deferred Data Loading
      
      Defer slow non-critical data to render critical content first. Return unawaited promises from the loader.
      
      Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/deferred-data-loading
      
      ### Returning Unawaited Promises
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params: { postId } }) => {
          const commentsPromise = fetchComments(postId)   // slow - do NOT await
          const post = await fetchPost(postId)             // fast - await
      
          return { post, deferredComments: commentsPromise }
        },
      })
      ```
      
      ### Await Component
      
      Resolve deferred promises using `Await`, which triggers the nearest Suspense boundary:
      
      ```tsx
      import { Await } from '@tanstack/react-router'
      import { Suspense } from 'react'
      
      function PostComponent() {
        const { post, deferredComments } = Route.useLoaderData()
      
        return (
          <div>
            <h1>{post.title}</h1>
            <Suspense fallback={<div>Loading comments...</div>}>
              <Await promise={deferredComments}>
                {(comments) => (
                  <ul>
                    {comments.map((c) => <li key={c.id}>{c.body}</li>)}
                  </ul>
                )}
              </Await>
            </Suspense>
          </div>
        )
      }
      ```
      
      If the promise rejects, `Await` throws the serialized error to the nearest error boundary. In React 19, you can use the `use()` hook instead.
      
      ### SSR Streaming
      
      Deferred data supports server-side streaming:
      
      1. Server renders up to Suspense boundaries and streams initial HTML.
      2. Deferred promises are tracked as they resolve on the server.
      3. Resolved data is serialized and streamed via inline script tags.
      4. Client-side placeholder promises resolve with the streamed data.
      
      See the [SSR Streaming Guide](https://tanstack.com/router/latest/docs/framework/react/guide/ssr) for setup.
      
      ### Deferred Data with TanStack Query
      
      Use `prefetchQuery` (no await) for deferred data, `ensureQueryData` (await) for critical data:
      
      ```tsx
      import { useSuspenseQuery } from '@tanstack/react-query'
      
      const postOptions = (postId: string) => queryOptions({
        queryKey: ['post', postId],
        queryFn: () => fetchPost(postId),
      })
      const commentsOptions = (postId: string) => queryOptions({
        queryKey: ['comments', postId],
        queryFn: () => fetchComments(postId),
      })
      
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params: { postId }, context: { queryClient } }) => {
          queryClient.prefetchQuery(commentsOptions(postId))          // deferred
          await queryClient.ensureQueryData(postOptions(postId))      // critical
        },
        component: PostComponent,
      })
      
      function PostComponent() {
        const { postId } = Route.useParams()
        const { data: post } = useSuspenseQuery(postOptions(postId))
        return (
          <div>
            <h1>{post.title}</h1>
            <Suspense fallback={<div>Loading comments...</div>}>
              <Comments postId={postId} />
            </Suspense>
          </div>
        )
      }
      
      function Comments({ postId }: { postId: string }) {
        const { data: comments } = useSuspenseQuery(commentsOptions(postId))
        return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>
      }
      ```
      
      ## External Data Loading - TanStack Query Integration
      
      TanStack Query is the most common integration. The router coordinates when to fetch; TanStack Query handles caching, deduplication, and background refetching.
      
      Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading
      
      ### Router Setup
      
      Set `defaultPreloadStaleTime: 0` so every event passes through to your external library:
      
      ```tsx
      const router = createRouter({
        routeTree,
        defaultPreloadStaleTime: 0,
        context: { queryClient },
      })
      ```
      
      ### ensureQueryData Pattern
      
      ```tsx
      import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
      
      const postsQueryOptions = queryOptions({
        queryKey: ['posts'],
        queryFn: () => fetchPosts(),
      })
      
      export const Route = createFileRoute('/posts')({
        loader: ({ context: { queryClient } }) =>
          queryClient.ensureQueryData(postsQueryOptions),
        component: () => {
          const { data: posts } = useSuspenseQuery(postsQueryOptions)
          return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
        },
      })
      ```
      
      ### Full SSR Setup
      
      For SSR, use `dehydrate`/`hydrate` options on the router to shuttle TanStack Query state between server and client:
      
      ```tsx
      import { QueryClient, QueryClientProvider, dehydrate, hydrate } from '@tanstack/react-query'
      
      export function createAppRouter() {
        const queryClient = new QueryClient()
        return createRouter({
          routeTree,
          defaultPreloadStaleTime: 0,
          context: { queryClient },
          dehydrate: () => ({ queryClientState: dehydrate(queryClient) }),
          hydrate: (dehydrated) => { hydrate(queryClient, dehydrated.queryClientState) },
          Wrap: ({ children }) => (
            <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
          ),
        })
      }
      ```
      
      ### Error Handling with TanStack Query
      
      Reset query error boundaries on mount to allow retry:
      
      ```tsx
      import { useQueryErrorResetBoundary } from '@tanstack/react-query'
      
      export const Route = createFileRoute('/posts')({
        loader: ({ context: { queryClient } }) =>
          queryClient.ensureQueryData(postsQueryOptions),
        errorComponent: ({ error }) => {
          const router = useRouter()
          const queryErrorResetBoundary = useQueryErrorResetBoundary()
      
          useEffect(() => { queryErrorResetBoundary.reset() }, [queryErrorResetBoundary])
      
          return (
            <div>
              <p>{error.message}</p>
              <button onClick={() => router.invalidate()}>Retry</button>
            </div>
          )
        },
      })
      ```
      
      ## Data Mutations
      
      TanStack Router has no built-in mutation APIs. It reacts to URL side-effects from external mutation events.
      
      Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/data-mutations
      
      ### router.invalidate()
      
      Force all active route loaders to reload after a mutation:
      
      ```tsx
      const router = useRouter()
      
      const handleCreate = async () => {
        await createPost({ title: 'New Post' })
        router.invalidate()                          // background reload
        // await router.invalidate({ sync: true })   // wait for completion
      }
      ```
      
      ### router.subscribe for Mutation State Cleanup
      
      Reset mutation states when navigation completes:
      
      ```tsx
      router.subscribe('onResolved', () => {
        mutationCache.clear()
      })
      ```
      
      The `onResolved` event fires when a location path change (not just reload) has fully resolved.
      
      ### Mutation with TanStack Query
      
      The standard pattern pairs TanStack Query mutations with router invalidation:
      
      ```tsx
      import { useMutation, useQueryClient } from '@tanstack/react-query'
      import { useRouter } from '@tanstack/react-router'
      
      function EditPost({ postId }: { postId: string }) {
        const router = useRouter()
        const queryClient = useQueryClient()
      
        const updatePost = useMutation({
          mutationFn: (data: { title: string }) =>
            fetch(`/api/posts/${postId}`, { method: 'PATCH', body: JSON.stringify(data) })
              .then((r) => r.json()),
          onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['posts'] })
            queryClient.invalidateQueries({ queryKey: ['post', postId] })
            router.invalidate()   // also reload router-cached loader data
          },
        })
      
        return <button onClick={() => updatePost.mutate({ title: 'Updated' })}>Save</button>
      }
      ```
      
      ## Loader Error Handling
      
      ### Throwing Errors, redirect(), and notFound()
      
      ```tsx
      import { createFileRoute, redirect, notFound } from '@tanstack/react-router'
      
      // Errors are caught by the route's error boundary
      export const Route = createFileRoute('/posts')({
        loader: async () => {
          const res = await fetch('/api/posts')
          if (!res.ok) throw new Error('Failed to fetch posts')
          return res.json()
        },
      })
      
      // redirect() accepts the same options as navigate
      export const Route = createFileRoute('/admin')({
        beforeLoad: ({ context }) => {
          if (!context.auth.isAdmin) throw redirect({ to: '/', replace: true })
        },
      })
      
      // notFound() renders the nearest notFoundComponent
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params: { postId } }) => {
          const post = await getPost(postId)
          if (!post) throw notFound()
          return { post }
        },
        notFoundComponent: () => {
          const { postId } = Route.useParams()
          return <p>Post {postId} not found</p>
        },
      })
      ```
      
      Note: `notFound()` in `beforeLoad` always triggers the root `notFoundComponent` since layout data may not have loaded.
      
      ### errorComponent with Retry
      
      ```tsx
      import { ErrorComponent, useRouter } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/posts')({
        loader: () => fetchPosts(),
        errorComponent: ({ error }) => {
          const router = useRouter()
          if (error instanceof CustomApiError) {
            return <div>API Error: {error.statusCode}</div>
          }
          return (
            <div>
              <p>{error.message}</p>
              <button onClick={() => router.invalidate()}>Retry</button>
            </div>
          )
        },
      })
      ```
      
      Use `router.invalidate()` instead of just `reset()` when the error came from a loader - it coordinates both reloading data and resetting the error boundary.
      
      ### onError, onCatch, and Pending Components
      
      ```tsx
      export const Route = createFileRoute('/posts')({
        loader: () => fetchPosts(),
        onError: ({ error }) => { reportToSentry(error) },         // on loader error
        onCatch: ({ error, errorInfo }) => { console.error(error) }, // on CatchBoundary catch
      })
      
      export const Route = createFileRoute('/reports')({
        loader: () => fetchLargeReport(),
        pendingComponent: () => <div>Generating report...</div>,
        pendingMs: 1_000,     // Show pending after 1s (default)
        pendingMinMs: 500,    // Show for at least 500ms to avoid flash (default)
      })
      ```
      
      Configure pending defaults at the router level with `defaultPendingMs` and `defaultPendingMinMs`.
      
      ## Common Patterns
      
      ### Parallel Data Loading and Preventing Waterfalls
      
      Route loaders across the same navigation run in parallel automatically. Within a single loader, use `Promise.all` for independent requests:
      
      ```tsx
      // routes/dashboard.tsx - runs in parallel with child loaders
      export const Route = createFileRoute('/dashboard')({
        loader: async () => {
          // Independent requests - fetch in parallel
          const [stats, notifications] = await Promise.all([
            fetchStats(),
            fetchNotifications(),
          ])
          return { stats, notifications }
        },
      })
      ```
      
      ### Dependent Data Loading
      
      Chain dependent fetches, but parallelize independent work:
      
      ```tsx
      export const Route = createFileRoute('/users/$userId/posts')({
        loader: async ({ params: { userId } }) => {
          const user = await fetchUser(userId)
          const [team, posts] = await Promise.all([
            fetchTeam(user.teamId),
            fetchUserPosts(userId),
          ])
          return { user, team, posts }
        },
      })
      ```
      
      ### Prefetch on Hover
      
      Enable at the router level with `defaultPreload: 'intent'`. Links will automatically preload on hover/focus. Disable per-link with `preload={false}`:
      
      ```tsx
      const router = createRouter({ routeTree, defaultPreload: 'intent' })
      ```
      
      ### Full Route: TanStack Query with Search Params
      
      Combines `validateSearch`, `loaderDeps`, `ensureQueryData`, and `useSuspenseQuery`:
      
      ```tsx
      import { z } from 'zod'
      import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
      
      const postsQueryOptions = (params: { page: number; limit: number }) =>
        queryOptions({ queryKey: ['posts', params], queryFn: () => fetchPosts(params) })
      
      export const Route = createFileRoute('/posts')({
        validateSearch: z.object({
          page: z.number().int().nonnegative().catch(0),
          limit: z.number().int().positive().catch(20),
        }),
        loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
        loader: ({ deps, context: { queryClient } }) =>
          queryClient.ensureQueryData(postsQueryOptions(deps)),
        component: () => {
          const { page, limit } = Route.useSearch()
          const { data: posts } = useSuspenseQuery(postsQueryOptions({ page, limit }))
          return (
            <div>
              <ul>{posts.items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
              <Link to="/posts" search={{ page: page + 1, limit }}>Next Page</Link>
            </div>
          )
        },
      })
      ```
      
      ## Router Cache vs External Cache
      
      **Use the built-in router cache when:** your app shares little data between routes, you want zero extra dependencies, coarse invalidation is acceptable, and SSR "just works" is a priority.
      
      **Use TanStack Query (or another external cache) when:** you need shared caching/deduplication across routes, fine-grained cache invalidation by query key, optimistic update APIs, mutation management, persistence adapters, or offline support.
      
      ## Best Practices
      
      1. **Always declare loaderDeps explicitly.** Only extract search params your loader uses. Never return the entire `search` object - it causes unnecessary cache invalidation.
      
      2. **Use beforeLoad for auth and redirects, loader for data.** `beforeLoad` runs serially and blocks children - right for access control. `loader` runs in parallel - right for data fetching.
      
      3. **Set `defaultPreloadStaleTime: 0` with external caches.** Ensures every event passes through to your library, which handles its own deduplication.
      
      4. **Pass the abort signal to fetch calls.** Use `abortController.signal` to cancel in-flight requests on navigation, preventing wasted bandwidth and race conditions.
      
      5. **Prefer `router.invalidate()` over `reset()` in error components.** It coordinates both reloading data and resetting the error boundary.
      
      6. **Defer non-critical data with unawaited promises.** Use `Await` or `useSuspenseQuery` with `prefetchQuery` to render deferred data progressively.
      
      7. **Use `Promise.all` for independent fetches within a loader.** Sequential awaits create waterfalls. Parallelize independent requests.
      
      8. **Avoid throwing `notFound()` in `beforeLoad`.** It always triggers the root `notFoundComponent`. Throw in `loader` instead for proper propagation.
      
    • infinite-queries.md 16.1 KB
      # Infinite Queries
      
      Infinite queries are used for implementing "load more" and infinite scroll patterns. They allow you to fetch paginated data progressively.
      
      ## Basic Infinite Query
      
      ```tsx
      import { useInfiniteQuery } from '@tanstack/react-query';
      
      function Posts() {
        const {
          data,
          fetchNextPage,
          hasNextPage,
          isFetchingNextPage,
          isLoading,
        } = useInfiniteQuery({
          queryKey: ['posts'],
          queryFn: async ({ pageParam = 0 }) => {
            const res = await fetch(`/api/posts?cursor=${pageParam}`);
            return res.json();
          },
          initialPageParam: 0,
          getNextPageParam: (lastPage, allPages) => {
            // Return undefined if no more pages
            return lastPage.nextCursor ?? undefined;
          },
        });
      
        if (isLoading) return <div>Loading...</div>;
      
        return (
          <>
            {data.pages.map((page, i) => (
              <div key={i}>
                {page.posts.map((post) => (
                  <div key={post.id}>{post.title}</div>
                ))}
              </div>
            ))}
            <button
              onClick={() => fetchNextPage()}
              disabled={!hasNextPage || isFetchingNextPage}
            >
              {isFetchingNextPage
                ? 'Loading more...'
                : hasNextPage
                ? 'Load More'
                : 'Nothing more to load'}
            </button>
          </>
        );
      }
      ```
      
      ## Data Structure
      
      The `data` object has a specific structure:
      
      ```tsx
      {
        pages: [
          { posts: [...], nextCursor: 1 },  // Page 1
          { posts: [...], nextCursor: 2 },  // Page 2
          { posts: [...], nextCursor: 3 },  // Page 3
        ],
        pageParams: [0, 1, 2] // The pageParam values used
      }
      ```
      
      ## Page Parameters
      
      ### initialPageParam
      
      Required parameter that specifies the initial page parameter:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: ({ pageParam }) => fetchPosts(pageParam),
        initialPageParam: 0, // or 1, or { cursor: null }, etc.
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      });
      ```
      
      ### getNextPageParam
      
      Function that receives the last page and all pages, and returns the next page parameter:
      
      ```tsx
      // Cursor-based pagination
      getNextPageParam: (lastPage, allPages) => {
        return lastPage.nextCursor ?? undefined;
      }
      
      // Offset-based pagination
      getNextPageParam: (lastPage, allPages) => {
        if (lastPage.length === 0) return undefined;
        return allPages.length * 10; // Assuming 10 items per page
      }
      
      // Page number pagination
      getNextPageParam: (lastPage, allPages) => {
        const totalPages = lastPage.totalPages;
        const nextPage = allPages.length + 1;
        return nextPage <= totalPages ? nextPage : undefined;
      }
      
      // Access to all pages
      getNextPageParam: (lastPage, allPages) => {
        const totalFetched = allPages.reduce((acc, page) => acc + page.data.length, 0);
        return totalFetched < lastPage.total ? lastPage.nextCursor : undefined;
      }
      ```
      
      ### getPreviousPageParam
      
      For bidirectional infinite scrolling:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: ({ pageParam }) => fetchPosts(pageParam),
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        getPreviousPageParam: (firstPage) => firstPage.previousCursor,
      });
      ```
      
      ## Fetching Pages
      
      ### Fetch Next Page
      
      ```tsx
      const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
        // ...config
      });
      
      // Manual trigger
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
        Load More
      </button>
      
      // Infinite scroll
      useEffect(() => {
        const handleScroll = () => {
          if (
            window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 &&
            hasNextPage &&
            !isFetchingNextPage
          ) {
            fetchNextPage();
          }
        };
      
        window.addEventListener('scroll', handleScroll);
        return () => window.removeEventListener('scroll', handleScroll);
      }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
      ```
      
      ### Fetch Previous Page
      
      ```tsx
      const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = useInfiniteQuery({
        // ...config
      });
      
      <button onClick={() => fetchPreviousPage()} disabled={!hasPreviousPage}>
        Load Previous
      </button>
      ```
      
      ## Pagination Strategies
      
      ### Cursor-Based Pagination
      
      Best for real-time data and when items can be inserted/deleted:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: async ({ pageParam }) => {
          const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
          return res.json();
        },
        initialPageParam: null,
        getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
      });
      
      // API response structure:
      // {
      //   data: [...],
      //   nextCursor: 'cursor_string' | null
      // }
      ```
      
      ### Offset-Based Pagination
      
      Simpler but can have issues with real-time data:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: async ({ pageParam = 0 }) => {
          const res = await fetch(`/api/posts?offset=${pageParam}&limit=10`);
          return res.json();
        },
        initialPageParam: 0,
        getNextPageParam: (lastPage, allPages) => {
          if (lastPage.data.length < 10) return undefined;
          return allPages.length * 10;
        },
      });
      ```
      
      ### Page Number Pagination
      
      Traditional page-based approach:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: async ({ pageParam = 1 }) => {
          const res = await fetch(`/api/posts?page=${pageParam}&size=10`);
          return res.json();
        },
        initialPageParam: 1,
        getNextPageParam: (lastPage, allPages) => {
          const currentPage = allPages.length;
          return currentPage < lastPage.totalPages ? currentPage + 1 : undefined;
        },
      });
      ```
      
      ## Infinite Scroll Implementation
      
      ### Using Intersection Observer
      
      ```tsx
      import { useInfiniteQuery } from '@tanstack/react-query';
      import { useRef, useCallback } from 'react';
      
      function InfiniteScrollPosts() {
        const observerTarget = useRef(null);
      
        const {
          data,
          fetchNextPage,
          hasNextPage,
          isFetchingNextPage,
        } = useInfiniteQuery({
          queryKey: ['posts'],
          queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
        });
      
        // Setup intersection observer
        useEffect(() => {
          const observer = new IntersectionObserver(
            (entries) => {
              if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
                fetchNextPage();
              }
            },
            { threshold: 1.0 }
          );
      
          if (observerTarget.current) {
            observer.observe(observerTarget.current);
          }
      
          return () => observer.disconnect();
        }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
      
        return (
          <div>
            {data?.pages.map((page, i) => (
              <div key={i}>
                {page.posts.map((post) => (
                  <PostCard key={post.id} post={post} />
                ))}
              </div>
            ))}
            {hasNextPage && (
              <div ref={observerTarget} className="loading-indicator">
                {isFetchingNextPage ? 'Loading more...' : 'Load more'}
              </div>
            )}
          </div>
        );
      }
      ```
      
      ### Using react-intersection-observer
      
      ```tsx
      import { useInfiniteQuery } from '@tanstack/react-query';
      import { useInView } from 'react-intersection-observer';
      
      function InfiniteScrollPosts() {
        const { ref, inView } = useInView();
      
        const {
          data,
          fetchNextPage,
          hasNextPage,
          isFetchingNextPage,
        } = useInfiniteQuery({
          queryKey: ['posts'],
          queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
        });
      
        useEffect(() => {
          if (inView && hasNextPage && !isFetchingNextPage) {
            fetchNextPage();
          }
        }, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]);
      
        return (
          <div>
            {data?.pages.map((page, i) => (
              <div key={i}>
                {page.posts.map((post) => (
                  <PostCard key={post.id} post={post} />
                ))}
              </div>
            ))}
            <div ref={ref}>{isFetchingNextPage && 'Loading...'}</div>
          </div>
        );
      }
      ```
      
      ## Refetching and Invalidation
      
      ### Refetch All Pages
      
      ```tsx
      const queryClient = useQueryClient();
      
      // Refetch all pages
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      
      // Or manually
      const { refetch } = useInfiniteQuery({ /* ... */ });
      refetch();
      ```
      
      ### Limiting Stored Pages with `maxPages`
      
      **v5 removed `refetchPage`** (the v4 way to refetch only some pages) in favor of `maxPages`, which caps how many pages are stored *and* refetched. This bounds memory and keeps invalidation refetches cheap - refetching every accumulated page was the problem `refetchPage` tried to work around.
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: ({ pageParam }) => fetchPosts(pageParam),
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        getPreviousPageParam: (firstPage) => firstPage.previousCursor, // required if maxPages drops from both ends
        maxPages: 3, // keep at most 3 pages; fetching a 4th drops the oldest
      });
      ```
      
      On invalidation the query refetches only the currently-stored pages (at most `maxPages`), sequentially from the first. Omit `maxPages` (default `undefined`) to keep every page.
      
      ## Transforming Data
      
      ### Flatten Pages
      
      ```tsx
      const { data } = useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        select: (data) => ({
          pages: [...data.pages],
          pageParams: [...data.pageParams],
          // Flatten all posts
          allPosts: data.pages.flatMap(page => page.posts),
        }),
      });
      
      // Now you can use data.allPosts directly
      return <div>{data?.allPosts.map(post => <PostCard key={post.id} post={post} />)}</div>;
      ```
      
      ### Filter and Transform
      
      ```tsx
      const { data } = useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        select: (data) => ({
          ...data,
          pages: data.pages.map(page => ({
            ...page,
            posts: page.posts.filter(post => !post.isDeleted),
          })),
        }),
      });
      ```
      
      ## Bidirectional Infinite Scrolling
      
      ```tsx
      function BidirectionalScroll() {
        const {
          data,
          fetchNextPage,
          fetchPreviousPage,
          hasNextPage,
          hasPreviousPage,
          isFetchingNextPage,
          isFetchingPreviousPage,
        } = useInfiniteQuery({
          queryKey: ['messages'],
          queryFn: ({ pageParam }) => fetchMessages(pageParam),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
          getPreviousPageParam: (firstPage) => firstPage.previousCursor,
        });
      
        return (
          <div>
            <button
              onClick={() => fetchPreviousPage()}
              disabled={!hasPreviousPage || isFetchingPreviousPage}
            >
              {isFetchingPreviousPage ? 'Loading...' : 'Load Older'}
            </button>
      
            {data?.pages.map((page, i) => (
              <div key={i}>
                {page.messages.map((message) => (
                  <MessageCard key={message.id} message={message} />
                ))}
              </div>
            ))}
      
            <button
              onClick={() => fetchNextPage()}
              disabled={!hasNextPage || isFetchingNextPage}
            >
              {isFetchingNextPage ? 'Loading...' : 'Load Newer'}
            </button>
          </div>
        );
      }
      ```
      
      ## Advanced Patterns
      
      ### Search with Infinite Scroll
      
      ```tsx
      function SearchResults({ searchTerm }) {
        const {
          data,
          fetchNextPage,
          hasNextPage,
          isFetchingNextPage,
        } = useInfiniteQuery({
          queryKey: ['search', searchTerm],
          queryFn: ({ pageParam = 0 }) =>
            fetch(`/api/search?q=${searchTerm}&cursor=${pageParam}`).then(r => r.json()),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
          enabled: searchTerm.length > 2, // Only search if term is long enough
        });
      
        return (
          <div>
            {data?.pages.map((page, i) => (
              <div key={i}>
                {page.results.map((result) => (
                  <SearchResult key={result.id} result={result} />
                ))}
              </div>
            ))}
            {hasNextPage && (
              <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
                Load More Results
              </button>
            )}
          </div>
        );
      }
      ```
      
      ### Infinite Query with Filters
      
      ```tsx
      function FilteredList({ filters }) {
        const {
          data,
          fetchNextPage,
          hasNextPage,
        } = useInfiniteQuery({
          queryKey: ['items', filters],
          queryFn: ({ pageParam = 0 }) =>
            fetchItems({ ...filters, cursor: pageParam }),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
        });
      
        // When filters change, query automatically resets and refetches from page 1
      
        return (
          <div>
            {data?.pages.map((page, i) => (
              <div key={i}>
                {page.items.map((item) => (
                  <ItemCard key={item.id} item={item} />
                ))}
              </div>
            ))}
            {hasNextPage && <button onClick={() => fetchNextPage()}>Load More</button>}
          </div>
        );
      }
      ```
      
      ### Prefetching Next Page
      
      ```tsx
      function Posts() {
        const queryClient = useQueryClient();
      
        const {
          data,
          fetchNextPage,
          hasNextPage,
        } = useInfiniteQuery({
          queryKey: ['posts'],
          queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
        });
      
        // Prefetch next page when user is near the end
        useEffect(() => {
          if (hasNextPage) {
            const nextPageParam = data?.pageParams[data.pageParams.length - 1] + 1;
            queryClient.prefetchInfiniteQuery({
              queryKey: ['posts'],
              queryFn: ({ pageParam }) => fetchPosts(pageParam),
              initialPageParam: 0,
              getNextPageParam: (lastPage) => lastPage.nextCursor,
              pages: data?.pages.length + 1, // Prefetch one more page
            });
          }
        }, [data, hasNextPage, queryClient]);
      
        return <div>{/* render */}</div>;
      }
      ```
      
      ## Streaming with Infinite Queries
      
      `experimental_streamedQuery` can be combined with infinite patterns for streaming paginated data:
      
      ```tsx
      import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query';
      
      const { data, fetchStatus } = useInfiniteQuery({
        queryKey: ['stream-feed'],
        queryFn: streamedQuery({
          streamFn: ({ pageParam }) => fetchStreamedFeed(pageParam),
          refetchMode: 'append',
        }),
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage?.nextCursor,
      });
      
      // Each page streams in chunks while fetchStatus === 'fetching'
      ```
      
      See the Streamed Queries section in the main SKILL.md for the full `streamedQuery` API.
      
      ## Common Issues and Solutions
      
      ### Duplicate Data After Invalidation
      
      When invalidating an infinite query, it refetches all stored pages. To avoid duplicates or excessive refetching:
      
      **SSR note:** v5.90.3 fixed unhandled promise rejections during dehydration/rehydration of pending infinite queries. Ensure you're on v5.90.3+ if using SSR with infinite queries.
      
      ```tsx
      // Option 1: Cap stored/refetched pages with maxPages (replaces v4 refetchPage)
      useInfiniteQuery({ queryKey: ['posts'], /* ... */, maxPages: 3 });
      
      // Option 2: Reset to first page
      queryClient.resetQueries({ queryKey: ['posts'] });
      ```
      
      ### Stale Data Between Pages
      
      Set appropriate `staleTime`:
      
      ```tsx
      useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        staleTime: 1000 * 60 * 5, // 5 minutes
      });
      ```
      
      ### Managing Total Count
      
      Track total items across all pages:
      
      ```tsx
      const { data } = useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
        select: (data) => ({
          ...data,
          totalCount: data.pages[0]?.total || 0, // Assuming API returns total
          currentCount: data.pages.reduce((acc, page) => acc + page.posts.length, 0),
        }),
      });
      
      // Display: Showing {data.currentCount} of {data.totalCount}
      ```
      
      ## Best Practices
      
      1. **Choose the Right Pagination Strategy**
         - Use cursor-based for real-time feeds
         - Use offset for simple lists
         - Use page numbers for traditional pagination
      
      2. **Handle Edge Cases**
         - Empty states when no data
         - Loading states for first page
         - Error states with retry
         - End of list indicators
      
      3. **Optimize Performance**
         - Use `select` to transform data once
         - Set appropriate `staleTime` and `gcTime`
         - Implement virtual scrolling for large lists (react-window, react-virtualized)
      
      4. **Refetch Strategies**
         - Only refetch first page for most updates
         - Use `refetchPage` for targeted refetches
         - Consider resetting queries when filters change
      
      5. **User Experience**
         - Show loading indicators for next/previous pages
         - Disable buttons during fetching
         - Provide feedback when no more data
         - Handle errors gracefully with retry options
      
    • middleware.md 20.1 KB
      # TanStack Start Middleware
      
      Middleware in TanStack Start lets you intercept and customize server requests (including SSR) and server functions. Middleware is composable, type-safe, and supports both client-side and server-side logic.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/middleware
      
      ## Two Types of Middleware
      
      ### Request Middleware
      
      Runs on every server request that passes through it - server routes, SSR, and server functions. Created with `createMiddleware()` (type defaults to `'request'`). Only has a `.server()` method.
      
      ### Server Function Middleware
      
      Runs specifically for server functions. Created with `createMiddleware({ type: 'function' })`. Has `.client()`, `.server()`, and `.validator()` methods. The `.client()` method wraps the RPC call on the client side.
      
      | Feature          | Request Middleware          | Server Function Middleware     |
      |------------------|----------------------------|--------------------------------|
      | Scope            | All server requests        | Server functions only          |
      | Methods          | `.server()`                | `.client()`, `.server()`       |
      | Input Validation | No                         | Yes (`.validator()`)      |
      | Client-side Logic| No                         | Yes                            |
      | Dependencies     | Request middleware only     | Both request and function types |
      
      ## Creating Request Middleware
      
      Request middleware receives `next`, `context`, `request`, `pathname`, and `serverFnMeta` (present only for server function calls) in its `.server()` callback.
      
      ```typescript
      import { createMiddleware } from '@tanstack/react-start'
      
      const loggingMiddleware = createMiddleware().server(
        async ({ next, context, request }) => {
          const startTime = Date.now()
          const url = new URL(request.url)
          console.log(`[${request.method}] ${url.pathname} - Starting`)
          const result = await next()
          console.log(
            `[${request.method}] ${url.pathname} - ${result.response.status} (${Date.now() - startTime}ms)`,
          )
          return result
        },
      )
      ```
      
      ## Creating Server Function Middleware
      
      Method order is enforced by TypeScript: `.middleware()` -> `.validator()` -> `.client()` -> `.server()`.
      
      ```typescript
      import { createMiddleware } from '@tanstack/react-start'
      
      const timingMiddleware = createMiddleware({ type: 'function' })
        .client(async ({ next }) => {
          const clientStart = Date.now()
          const result = await next() // triggers RPC to server
          console.log(`RPC round-trip: ${Date.now() - clientStart}ms`)
          return result
        })
        .server(async ({ next }) => {
          const serverStart = Date.now()
          const result = await next()
          console.log(`Server execution: ${Date.now() - serverStart}ms`)
          return result
        })
      ```
      
      The `.client()` callback receives `next`, `context`, `data`, `method`, `signal`, `serverFnMeta`, and `filename`. The `.server()` callback receives `next`, `context`, `data`, `method`, `serverFnMeta`, and `signal`.
      
      ### The .validator() Method
      
      Validates and transforms input data before it reaches the middleware chain. Works with `zodValidator`, `valibotValidator`, and `arktypeValidator`.
      
      > The method is `.validator()`. The older `.inputValidator()` spelling is deprecated and the compiler now emits warnings for it (TanStack/router PR #7566).
      
      ```typescript
      import { createMiddleware } from '@tanstack/react-start'
      import { zodValidator } from '@tanstack/zod-adapter'
      import { z } from 'zod'
      
      const workspaceMiddleware = createMiddleware({ type: 'function' })
        .validator(zodValidator(z.object({ workspaceId: z.string().uuid() })))
        .server(({ next, data }) => {
          console.log('Validated workspace ID:', data.workspaceId)
          return next()
        })
      ```
      
      ## Middleware Composition
      
      Middleware depends on other middleware through `.middleware()`. Dependencies execute first, and their context accumulates.
      
      ```typescript
      import { createMiddleware } from '@tanstack/react-start'
      
      const loggingMiddleware = createMiddleware().server(async ({ next }) => {
        console.log('Logging: request started')
        return next()
      })
      
      const authMiddleware = createMiddleware()
        .middleware([loggingMiddleware])
        .server(async ({ next }) => {
          // loggingMiddleware runs first, then this
          return next({ context: { userId: 'user-123' } })
        })
      
      const adminMiddleware = createMiddleware()
        .middleware([authMiddleware])
        .server(async ({ next, context }) => {
          // logging -> auth -> admin
          if (context.userId !== 'admin') {
            throw new Error('Forbidden')
          }
          return next()
        })
      ```
      
      Rules: request middleware can only depend on request middleware. Function middleware can depend on both types.
      
      ## next() and Context Management
      
      ### Providing Context
      
      Call `next()` with a `context` object to pass typed data downstream. Context merges into the parent context.
      
      ```typescript
      const featureFlagMiddleware = createMiddleware({ type: 'function' }).server(
        ({ next }) => {
          return next({
            context: { features: { newDashboard: true, betaAPI: false } },
          })
        },
      )
      
      const consumer = createMiddleware({ type: 'function' })
        .middleware([featureFlagMiddleware])
        .server(async ({ next, context }) => {
          console.log('Dashboard enabled:', context.features.newDashboard) // typed
          return next()
        })
      ```
      
      ### Sending Client Context to the Server
      
      Client context is NOT sent to the server by default. Use `sendContext` in `.client()` to explicitly transmit data.
      
      ```typescript
      const workspaceMiddleware = createMiddleware({ type: 'function' })
        .client(async ({ next, context }) => {
          return next({ sendContext: { workspaceId: context.workspaceId } })
        })
        .server(async ({ next, context }) => {
          // Validate dynamic data before trusting it
          const workspaceId = zodValidator(z.string().uuid()).parse(context.workspaceId)
          return next({ context: { validatedWorkspaceId: workspaceId } })
        })
      ```
      
      ### Sending Server Context to the Client
      
      Use `sendContext` in `.server()` to send data back. Access it via `result.context` in the `.client()` method.
      
      ```typescript
      const serverTimer = createMiddleware({ type: 'function' }).server(
        async ({ next }) => {
          return next({ sendContext: { serverTimestamp: new Date().toISOString() } })
        },
      )
      
      const clientLogger = createMiddleware({ type: 'function' })
        .middleware([serverTimer])
        .client(async ({ next }) => {
          const result = await next()
          console.log('Server time:', result.context.serverTimestamp) // typed
          return result
        })
      ```
      
      ## Authentication Middleware - Complete Flow
      
      ### Session Setup
      
      ```typescript
      // utils/session.ts
      import { useSession } from '@tanstack/react-start/server'
      
      type SessionData = {
        userId?: string
        email?: string
        role?: 'user' | 'admin' | 'moderator'
      }
      
      export function useAppSession() {
        return useSession<SessionData>({
          name: 'app-session',
          password: process.env.SESSION_SECRET!, // at least 32 characters
          cookie: {
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'lax',
            httpOnly: true,
            maxAge: 7 * 24 * 60 * 60,
          },
        })
      }
      ```
      
      ### Auth Server Functions
      
      ```typescript
      // utils/auth.ts
      import { createServerFn } from '@tanstack/react-start'
      import { redirect } from '@tanstack/react-router'
      import { useAppSession } from './session'
      
      export const loginFn = createServerFn({ method: 'POST' })
        .validator((d: { email: string; password: string }) => d)
        .handler(async ({ data }) => {
          const user = await authenticateUser(data.email, data.password)
          if (!user) {
            return { error: true, message: 'Invalid credentials' }
          }
          const session = await useAppSession()
          await session.update({ userId: user.id, email: user.email, role: user.role })
          throw redirect({ to: '/dashboard' })
        })
      
      export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler(
        async () => {
          const session = await useAppSession()
          if (!session.data.userId) return null
          return getUserById(session.data.userId)
        },
      )
      ```
      
      ### Auth Middleware and Route Protection
      
      ```typescript
      // middleware/auth.ts
      import { createMiddleware } from '@tanstack/react-start'
      import { useAppSession } from '../utils/session'
      
      export const authMiddleware = createMiddleware().server(async ({ next }) => {
        const session = await useAppSession()
        return next({
          context: {
            userId: session.data.userId ?? null,
            userRole: session.data.role ?? null,
            isAuthenticated: !!session.data.userId,
          },
        })
      })
      ```
      
      ```typescript
      // routes/_authed.tsx - layout route guard
      import { createFileRoute, redirect } from '@tanstack/react-router'
      import { getCurrentUserFn } from '../utils/auth'
      
      export const Route = createFileRoute('/_authed')({
        beforeLoad: async ({ location }) => {
          const user = await getCurrentUserFn()
          if (!user) {
            throw redirect({ to: '/login', search: { redirect: location.href } })
          }
          return { user }
        },
      })
      ```
      
      ```typescript
      // routes/_authed/dashboard.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/_authed/dashboard')({
        component: () => {
          const { user } = Route.useRouteContext()
          return <h1>Welcome, {user.email}!</h1>
        },
      })
      ```
      
      ```typescript
      // routes/_authed/admin.tsx - role-based guard
      import { createFileRoute, redirect } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/_authed/admin')({
        beforeLoad: async ({ context }) => {
          if (context.user.role !== 'admin') {
            throw redirect({ to: '/unauthorized' })
          }
        },
      })
      ```
      
      ## Global Middleware
      
      Configure in `src/start.ts` (not included in the default template - create it when needed).
      
      ```typescript
      // src/start.ts
      import { createStart, createMiddleware } from '@tanstack/react-start'
      
      const globalLogger = createMiddleware().server(async ({ request, next }) => {
        const start = Date.now()
        const result = await next()
        console.log(`${request.method} ${new URL(request.url).pathname} ${result.response.status} ${Date.now() - start}ms`)
        return result
      })
      
      const fnTimer = createMiddleware({ type: 'function' }).server(
        async ({ next, serverFnMeta }) => {
          const start = Date.now()
          const result = await next()
          console.log(`ServerFn ${serverFnMeta.id}: ${Date.now() - start}ms`)
          return result
        },
      )
      
      export const startInstance = createStart(() => ({
        requestMiddleware: [globalLogger],     // runs on every request (SSR, routes, fns)
        functionMiddleware: [fnTimer],          // runs on every server function
      }))
      ```
      
      ### Execution Order
      
      Dependency-first, then global middleware, then the chain:
      
      ```typescript
      // With globalMiddleware1, globalMiddleware2 in start.ts:
      const a = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('a'); return next() })
      const b = createMiddleware({ type: 'function' }).middleware([a]).server(async ({ next }) => { console.log('b'); return next() })
      const c = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('c'); return next() })
      const d = createMiddleware({ type: 'function' }).middleware([b, c]).server(async ({ next }) => { console.log('d'); return next() })
      const fn = createServerFn().middleware([d]).handler(async () => { console.log('fn') })
      // Order: globalMiddleware1 -> globalMiddleware2 -> a -> b -> c -> d -> fn
      ```
      
      ## Usage with Server Functions
      
      ```typescript
      import { createServerFn } from '@tanstack/react-start'
      
      const getProtectedDataFn = createServerFn()
        .middleware([authMiddleware])
        .handler(async ({ context }) => {
          if (!context.isAuthenticated) throw new Error('Unauthorized')
          return fetchDataForUser(context.userId)
        })
      ```
      
      ## Usage with Server Routes
      
      ### Route-Level Middleware (All Methods)
      
      ```typescript
      export const Route = createFileRoute('/api/data')({
        server: {
          middleware: [corsMiddleware, authMiddleware],
          handlers: {
            GET: async () => Response.json({ items: await fetchItems() }),
            POST: async ({ request }) => Response.json({ created: await createItem(await request.json()) }),
          },
        },
      })
      ```
      
      ### Handler-Specific Middleware
      
      Use `createHandlers` to attach middleware to individual HTTP methods. Route-level middleware runs first, then handler-specific.
      
      ```typescript
      export const Route = createFileRoute('/api/items')({
        server: {
          middleware: [corsMiddleware],
          handlers: ({ createHandlers }) =>
            createHandlers({
              GET: async () => Response.json({ items: await fetchItems() }),
              POST: {
                middleware: [writeAuthMiddleware],
                handler: async ({ request }) => Response.json(await createItem(await request.json())),
              },
            }),
        },
      })
      ```
      
      ## Client Request Modification
      
      ### Custom Headers
      
      ```typescript
      const authHeaderMiddleware = createMiddleware({ type: 'function' }).client(
        async ({ next }) => {
          return next({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
        },
      )
      ```
      
      Headers merge across middleware. Precedence: call-site headers > later middleware > earlier middleware.
      
      ### Custom Fetch
      
      Provide a custom `fetch` for retries, logging, or testing. Precedence (highest to lowest): call site > later middleware > earlier middleware > `createStart({ serverFns: { fetch } })` > default `fetch`. Custom fetch only applies client-side; during SSR, server functions are called directly.
      
      ```typescript
      import type { CustomFetch } from '@tanstack/react-start'
      
      const retryMiddleware = createMiddleware({ type: 'function' }).client(
        async ({ next }) => {
          const retryFetch: CustomFetch = async (url, init) => {
            for (let attempt = 0; attempt < 3; attempt++) {
              try { return await fetch(url, init) }
              catch { await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt))) }
            }
            throw new Error('Max retries exceeded')
          }
          return next({ fetch: retryFetch })
        },
      )
      ```
      
      ## Validation Adapters
      
      ```typescript
      // Zod
      import { zodValidator } from '@tanstack/zod-adapter'
      import { z } from 'zod'
      
      const paginationMiddleware = createMiddleware({ type: 'function' })
        .validator(zodValidator(z.object({ page: z.number().default(1), limit: z.number().max(100).default(20) })))
        .server(({ next, data }) => { console.log(`Page ${data.page}`); return next() })
      
      // Valibot
      import { valibotValidator } from '@tanstack/valibot-adapter'
      import * as v from 'valibot'
      
      const vMiddleware = createMiddleware({ type: 'function' })
        .validator(valibotValidator(v.object({ workspaceId: v.pipe(v.string(), v.uuid()) })))
        .server(({ next, data }) => next({ context: { workspaceId: data.workspaceId } }))
      
      // ArkType
      import { arktypeValidator } from '@tanstack/arktype-adapter'
      import { type } from 'arktype'
      
      const aMiddleware = createMiddleware({ type: 'function' })
        .validator(arktypeValidator(type({ tenantId: 'string' })))
        .server(({ next, data }) => next({ context: { tenantId: data.tenantId } }))
      ```
      
      ## Common Patterns
      
      ### CORS Middleware
      
      ```typescript
      export const corsMiddleware = createMiddleware().server(async ({ next }) => {
        const result = await next()
        result.response.headers.set('Access-Control-Allow-Origin', process.env.ALLOWED_ORIGIN || '*')
        result.response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
        result.response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
        result.response.headers.set('Access-Control-Max-Age', '86400')
        return result
      })
      ```
      
      ### Security Headers
      
      ```typescript
      export const securityHeaders = createMiddleware().server(async ({ next }) => {
        const result = await next()
        result.response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'")
        result.response.headers.set('X-Content-Type-Options', 'nosniff')
        result.response.headers.set('X-Frame-Options', 'DENY')
        result.response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
        return result
      })
      ```
      
      ### Rate Limiting
      
      ```typescript
      const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
      
      export const rateLimitMiddleware = createMiddleware().server(
        async ({ request, next }) => {
          const ip = request.headers.get('x-forwarded-for') || 'unknown'
          const now = Date.now()
          const entry = rateLimitStore.get(ip)
      
          if (!entry || now > entry.resetTime) {
            rateLimitStore.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 })
          } else if (entry.count >= 100) {
            return new Response('Too Many Requests', {
              status: 429,
              headers: { 'Retry-After': Math.ceil((entry.resetTime - now) / 1000).toString() },
            })
          } else {
            entry.count++
          }
          return next()
        },
      )
      ```
      
      ### Composition Chain Example
      
      ```typescript
      import { createMiddleware, createServerFn } from '@tanstack/react-start'
      import { useAppSession } from '../utils/session'
      
      // Layer 1: Logging
      const logging = createMiddleware().server(async ({ request, next }) => {
        const start = Date.now()
        const result = await next()
        console.log(`${request.method} ${new URL(request.url).pathname} ${Date.now() - start}ms`)
        return result
      })
      
      // Layer 2: Auth context - depends on logging
      const auth = createMiddleware()
        .middleware([logging])
        .server(async ({ next }) => {
          const session = await useAppSession()
          return next({
            context: { userId: session.data.userId ?? null, isAuthenticated: !!session.data.userId },
          })
        })
      
      // Layer 3: Require auth - depends on auth
      const requireAuth = createMiddleware()
        .middleware([auth])
        .server(async ({ next, context }) => {
          if (!context.isAuthenticated) return new Response('Unauthorized', { status: 401 })
          return next()
        })
      
      // Usage: logging -> auth -> requireAuth -> handler
      const getProfileFn = createServerFn()
        .middleware([requireAuth])
        .handler(async ({ context }) => fetchUserProfile(context.userId))
      ```
      
      ## Response Modification and Short-Circuiting
      
      Request middleware can modify responses or return a `Response` directly to short-circuit.
      
      ```typescript
      const debugMiddleware = createMiddleware().server(async ({ next }) => {
        const result = await next()
        if (process.env.NODE_ENV === 'development') {
          result.response.headers.set('X-Debug-Timestamp', new Date().toISOString())
        }
        return result
      })
      
      const maintenanceMiddleware = createMiddleware().server(async ({ next }) => {
        if (process.env.MAINTENANCE_MODE === 'true') {
          return new Response('Service temporarily unavailable', { status: 503 })
        }
        return next()
      })
      ```
      
      ## Environment Tree Shaking
      
      - **Server bundle**: All middleware code is included
      - **Client bundle**: `.server()` and `data` validation code are removed
      
      You can safely import server-only dependencies in `.server()` callbacks.
      
      ## Best Practices
      
      1. **Start with request middleware for cross-cutting concerns.** Logging, security headers, CORS, and auth session resolution apply to all requests including SSR.
      
      2. **Use function middleware only when you need `.client()` or `.validator()`.** If you only need server-side logic, request middleware is simpler.
      
      3. **Always return the result of next().** Forgetting to return breaks the pipeline.
      
      4. **Validate sendContext on the server.** Client-sent context is type-safe but not runtime-validated. Validate dynamic data before trusting it.
      
      5. **Create src/start.ts for global middleware.** This file is not in the default template. Create it for `requestMiddleware` or `functionMiddleware`.
      
      6. **Compose small units.** Build focused middleware and compose via `.middleware([...])` rather than duplicating logic.
      
      7. **The auth boundary is the endpoint, not the route.** Authorize every server function and server route that reads or writes private data inside its handler or middleware - they are reachable independently of whichever route rendered the calling UI. `beforeLoad` on layout routes like `_authed.tsx` is route UX (redirects, navigation control), not the data-authorization boundary. Use both, but never rely on a route guard to protect data. Test direct unauthenticated calls to protected server functions - they must reject before returning data.
      
      ## Official References
      
      - Middleware: https://tanstack.com/start/latest/docs/framework/react/guide/middleware
      - Authentication: https://tanstack.com/start/latest/docs/framework/react/guide/authentication
      - Server functions: https://tanstack.com/start/latest/docs/framework/react/guide/server-functions
      - Server routes: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes
      - DIY auth example: https://github.com/TanStack/router/tree/main/examples/react/start-basic-auth
      
    • optimistic-updates.md 16.3 KB
      # Optimistic Updates
      
      Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state.
      
      ## Basic Optimistic Update
      
      ```tsx
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      
      function TodoList() {
        const queryClient = useQueryClient();
      
        const toggleTodo = useMutation({
          mutationFn: (todoId) => {
            return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' });
          },
          onMutate: async (todoId) => {
            // Cancel outgoing refetches
            await queryClient.cancelQueries({ queryKey: ['todos'] });
      
            // Snapshot the previous value
            const previousTodos = queryClient.getQueryData(['todos']);
      
            // Optimistically update
            queryClient.setQueryData(['todos'], (old) =>
              old.map((todo) =>
                todo.id === todoId ? { ...todo, done: !todo.done } : todo
              )
            );
      
            // Return context with previous value
            return { previousTodos };
          },
          onError: (err, todoId, context) => {
            // Rollback on error
            queryClient.setQueryData(['todos'], context.previousTodos);
          },
          onSettled: () => {
            // Refetch after mutation completes
            queryClient.invalidateQueries({ queryKey: ['todos'] });
          },
        });
      
        return (
          <div>
            {/* render todos with toggle */}
            <button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button>
          </div>
        );
      }
      ```
      
      ## Mutation Lifecycle
      
      Understanding the mutation lifecycle is crucial for optimistic updates:
      
      ```tsx
      const mutation = useMutation({
        mutationFn: updateTodo,
      
        // 1. Before mutation function runs
        onMutate: async (variables) => {
          // Cancel queries, snapshot data, optimistically update
          // Return context object
          return { previousData };
        },
      
        // 2. If mutation succeeds
        onSuccess: (data, variables, context) => {
          // Handle successful mutation
          // data = mutation function response
          // variables = mutation variables
          // context = returned from onMutate
        },
      
        // 3. If mutation fails
        onError: (error, variables, context) => {
          // Rollback optimistic update
          // error = error object
          // context = returned from onMutate
        },
      
        // 4. Always runs after success or error
        onSettled: (data, error, variables, context) => {
          // Refetch to sync with server
        },
      });
      ```
      
      ## Optimistic Update Patterns
      
      ### Adding an Item
      
      ```tsx
      const addTodo = useMutation({
        mutationFn: (newTodo) => {
          return fetch('/api/todos', {
            method: 'POST',
            body: JSON.stringify(newTodo),
          }).then(res => res.json());
        },
        onMutate: async (newTodo) => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previousTodos = queryClient.getQueryData(['todos']);
      
          // Add optimistic todo with temporary ID
          queryClient.setQueryData(['todos'], (old) => [
            ...old,
            { ...newTodo, id: 'temp-' + Date.now(), status: 'pending' },
          ]);
      
          return { previousTodos };
        },
        onError: (err, newTodo, context) => {
          queryClient.setQueryData(['todos'], context.previousTodos);
        },
        onSuccess: (data) => {
          // Replace temporary item with real server response
          queryClient.setQueryData(['todos'], (old) =>
            old.map((todo) =>
              todo.id.toString().startsWith('temp-') ? data : todo
            )
          );
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ### Updating an Item
      
      ```tsx
      const updateTodo = useMutation({
        mutationFn: ({ id, updates }) => {
          return fetch(`/api/todos/${id}`, {
            method: 'PATCH',
            body: JSON.stringify(updates),
          }).then(res => res.json());
        },
        onMutate: async ({ id, updates }) => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previousTodos = queryClient.getQueryData(['todos']);
      
          queryClient.setQueryData(['todos'], (old) =>
            old.map((todo) =>
              todo.id === id ? { ...todo, ...updates } : todo
            )
          );
      
          return { previousTodos };
        },
        onError: (err, variables, context) => {
          queryClient.setQueryData(['todos'], context.previousTodos);
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      
      // Usage
      updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } });
      ```
      
      ### Deleting an Item
      
      ```tsx
      const deleteTodo = useMutation({
        mutationFn: (todoId) => {
          return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
        },
        onMutate: async (todoId) => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previousTodos = queryClient.getQueryData(['todos']);
      
          queryClient.setQueryData(['todos'], (old) =>
            old.filter((todo) => todo.id !== todoId)
          );
      
          return { previousTodos };
        },
        onError: (err, todoId, context) => {
          queryClient.setQueryData(['todos'], context.previousTodos);
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ## Multiple Query Updates
      
      Update multiple related queries optimistically:
      
      ```tsx
      const updateUser = useMutation({
        mutationFn: ({ userId, updates }) => {
          return fetch(`/api/users/${userId}`, {
            method: 'PATCH',
            body: JSON.stringify(updates),
          }).then(res => res.json());
        },
        onMutate: async ({ userId, updates }) => {
          // Cancel all related queries
          await queryClient.cancelQueries({ queryKey: ['users'] });
          await queryClient.cancelQueries({ queryKey: ['user', userId] });
      
          // Snapshot previous data
          const previousUsers = queryClient.getQueryData(['users']);
          const previousUser = queryClient.getQueryData(['user', userId]);
      
          // Update users list
          queryClient.setQueryData(['users'], (old) =>
            old?.map((user) =>
              user.id === userId ? { ...user, ...updates } : user
            )
          );
      
          // Update individual user
          queryClient.setQueryData(['user', userId], (old) => ({
            ...old,
            ...updates,
          }));
      
          return { previousUsers, previousUser };
        },
        onError: (err, { userId }, context) => {
          // Rollback both queries
          queryClient.setQueryData(['users'], context.previousUsers);
          queryClient.setQueryData(['user', userId], context.previousUser);
        },
        onSettled: (data, error, { userId }) => {
          queryClient.invalidateQueries({ queryKey: ['users'] });
          queryClient.invalidateQueries({ queryKey: ['user', userId] });
        },
      });
      ```
      
      ## Optimistic Updates with Infinite Queries
      
      ```tsx
      const addPost = useMutation({
        mutationFn: (newPost) => {
          return fetch('/api/posts', {
            method: 'POST',
            body: JSON.stringify(newPost),
          }).then(res => res.json());
        },
        onMutate: async (newPost) => {
          await queryClient.cancelQueries({ queryKey: ['posts'] });
          const previousPosts = queryClient.getQueryData(['posts']);
      
          // Add to first page
          queryClient.setQueryData(['posts'], (old) => {
            if (!old?.pages.length) return old;
      
            return {
              ...old,
              pages: [
                {
                  ...old.pages[0],
                  posts: [
                    { ...newPost, id: 'temp-' + Date.now() },
                    ...old.pages[0].posts,
                  ],
                },
                ...old.pages.slice(1),
              ],
            };
          });
      
          return { previousPosts };
        },
        onError: (err, newPost, context) => {
          queryClient.setQueryData(['posts'], context.previousPosts);
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['posts'] });
        },
      });
      ```
      
      ## UI Feedback During Optimistic Updates
      
      ### Show Pending State
      
      ```tsx
      function TodoItem({ todo }) {
        const queryClient = useQueryClient();
      
        const toggleTodo = useMutation({
          mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }),
          onMutate: async (todoId) => {
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData(['todos']);
      
            queryClient.setQueryData(['todos'], (old) =>
              old.map((t) =>
                t.id === todoId
                  ? { ...t, done: !t.done, isPending: true } // Mark as pending
                  : t
              )
            );
      
            return { previousTodos };
          },
          onSuccess: (data, todoId) => {
            // Remove pending state
            queryClient.setQueryData(['todos'], (old) =>
              old.map((t) =>
                t.id === todoId ? { ...t, isPending: false } : t
              )
            );
          },
          onError: (err, todoId, context) => {
            queryClient.setQueryData(['todos'], context.previousTodos);
          },
        });
      
        return (
          <div className={todo.isPending ? 'opacity-50' : ''}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleTodo.mutate(todo.id)}
              disabled={todo.isPending}
            />
            {todo.title}
          </div>
        );
      }
      ```
      
      ### Show Error State
      
      ```tsx
      const [error, setError] = useState(null);
      
      const updateTodo = useMutation({
        mutationFn: updateTodoApi,
        onMutate: async (updates) => {
          setError(null); // Clear previous errors
          // ... optimistic update
        },
        onError: (err, variables, context) => {
          setError(err.message);
          // ... rollback
        },
      });
      
      return (
        <div>
          {error && <div className="error">{error}</div>}
          {/* render todo */}
        </div>
      );
      ```
      
      ## Advanced Patterns
      
      ### Optimistic Update with Retry
      
      ```tsx
      const updateTodo = useMutation({
        mutationFn: updateTodoApi,
        retry: 3,
        onMutate: async (updates) => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previousTodos = queryClient.getQueryData(['todos']);
      
          queryClient.setQueryData(['todos'], (old) =>
            old.map((todo) =>
              todo.id === updates.id
                ? { ...todo, ...updates, _optimistic: true }
                : todo
            )
          );
      
          return { previousTodos };
        },
        onSuccess: (data, variables) => {
          // Remove optimistic flag
          queryClient.setQueryData(['todos'], (old) =>
            old.map((todo) =>
              todo.id === variables.id
                ? { ...todo, _optimistic: false }
                : todo
            )
          );
        },
        onError: (err, variables, context) => {
          // Only rollback if all retries failed
          if (err.retryCount >= 3) {
            queryClient.setQueryData(['todos'], context.previousTodos);
          }
        },
      });
      ```
      
      ### Debounced Optimistic Updates
      
      For rapid updates like typing in a search or editing text:
      
      ```tsx
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      import { useDebouncedCallback } from 'use-debounce';
      
      function TodoTitle({ todo }) {
        const queryClient = useQueryClient();
        const [localTitle, setLocalTitle] = useState(todo.title);
      
        const updateTodo = useMutation({
          mutationFn: ({ id, title }) => {
            return fetch(`/api/todos/${id}`, {
              method: 'PATCH',
              body: JSON.stringify({ title }),
            }).then(res => res.json());
          },
          onMutate: async ({ id, title }) => {
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData(['todos']);
      
            queryClient.setQueryData(['todos'], (old) =>
              old.map((t) => (t.id === id ? { ...t, title } : t))
            );
      
            return { previousTodos };
          },
          onError: (err, variables, context) => {
            queryClient.setQueryData(['todos'], context.previousTodos);
            setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title);
          },
        });
      
        const debouncedUpdate = useDebouncedCallback(
          (id, title) => updateTodo.mutate({ id, title }),
          500
        );
      
        const handleChange = (e) => {
          const newTitle = e.target.value;
          setLocalTitle(newTitle);
          debouncedUpdate(todo.id, newTitle);
        };
      
        return <input value={localTitle} onChange={handleChange} />;
      }
      ```
      
      ### Optimistic Delete with Undo
      
      ```tsx
      function TodoItem({ todo }) {
        const queryClient = useQueryClient();
        const [showUndo, setShowUndo] = useState(false);
      
        const deleteTodo = useMutation({
          mutationFn: (todoId) => {
            return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
          },
          onMutate: async (todoId) => {
            setShowUndo(true);
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData(['todos']);
      
            queryClient.setQueryData(['todos'], (old) =>
              old.filter((t) => t.id !== todoId)
            );
      
            // Auto-hide undo after 5 seconds
            setTimeout(() => setShowUndo(false), 5000);
      
            return { previousTodos };
          },
          onError: (err, todoId, context) => {
            queryClient.setQueryData(['todos'], context.previousTodos);
            setShowUndo(false);
          },
        });
      
        const handleUndo = () => {
          deleteTodo.reset(); // Reset mutation state
          queryClient.invalidateQueries({ queryKey: ['todos'] });
          setShowUndo(false);
        };
      
        if (showUndo) {
          return (
            <div className="undo-banner">
              Todo deleted <button onClick={handleUndo}>Undo</button>
            </div>
          );
        }
      
        return (
          <div>
            {todo.title}
            <button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
          </div>
        );
      }
      ```
      
      ### Batch Optimistic Updates
      
      ```tsx
      const markAllDone = useMutation({
        mutationFn: () => {
          return fetch('/api/todos/mark-all-done', { method: 'POST' });
        },
        onMutate: async () => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previousTodos = queryClient.getQueryData(['todos']);
      
          queryClient.setQueryData(['todos'], (old) =>
            old.map((todo) => ({ ...todo, done: true }))
          );
      
          return { previousTodos };
        },
        onError: (err, variables, context) => {
          queryClient.setQueryData(['todos'], context.previousTodos);
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ## Cancel In-Flight Mutations
      
      Cancel mutations that are no longer needed:
      
      ```tsx
      function QuickEdit({ todo }) {
        const queryClient = useQueryClient();
      
        const updateTodo = useMutation({
          mutationFn: ({ id, title }) => {
            return fetch(`/api/todos/${id}`, {
              method: 'PATCH',
              body: JSON.stringify({ title }),
            }).then(res => res.json());
          },
          onMutate: async ({ id, title }) => {
            // Cancel previous mutations for this todo
            queryClient.cancelMutations({ mutationKey: ['updateTodo', id] });
      
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData(['todos']);
      
            queryClient.setQueryData(['todos'], (old) =>
              old.map((t) => (t.id === id ? { ...t, title } : t))
            );
      
            return { previousTodos };
          },
        });
      
        return (
          <input
            onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })}
          />
        );
      }
      ```
      
      ### Known Issues (Fixed)
      
      - **initialData mount timing (fixed v5.87.1):** When using `initialData` with optimistic updates, a race condition could cause the optimistic value to be overwritten by `initialData` on component remount during the mutation. Fixed in v5.87.1 - if you see stale data flash after optimistic updates, upgrade to this version or later.
      
      ## Best Practices
      
      1. **Always Cancel Queries**
         ```tsx
         await queryClient.cancelQueries({ queryKey: ['todos'] });
         ```
         Prevents race conditions between optimistic update and ongoing fetches.
      
      2. **Always Return Context**
         ```tsx
         onMutate: async (variables) => {
           const previousData = queryClient.getQueryData(['todos']);
           // ... update
           return { previousData }; // Critical for rollback
         }
         ```
      
      3. **Always Handle Errors**
         ```tsx
         onError: (err, variables, context) => {
           queryClient.setQueryData(['todos'], context.previousData);
         }
         ```
      
      4. **Use onSettled for Refetch**
         ```tsx
         onSettled: () => {
           queryClient.invalidateQueries({ queryKey: ['todos'] });
         }
         ```
         Ensures data stays in sync with server.
      
      5. **Show Visual Feedback**
         - Add loading/pending states to optimistically updated items
         - Show error messages on failure
         - Provide undo functionality where appropriate
      
      6. **Handle Multiple Related Queries**
         - Update all queries that display the same data
         - Rollback all queries on error
      
      7. **Consider Using Temporary IDs**
         - For created items, use temp IDs until server responds
         - Replace with server IDs on success
      
      8. **Test Error Cases**
         - Verify rollback works correctly
         - Test network failures
         - Test validation errors from server
      
      9. **Use queryOptions() for Type Safety**
         ```tsx
         const todosOptions = queryOptions({
           queryKey: ['todos'],
           queryFn: fetchTodos,
         });
      
         // Reuse in mutation callbacks for consistent types
         onMutate: async () => {
           const previous = queryClient.getQueryData(todosOptions.queryKey);
           // TypeScript knows the exact type of previous
           return { previous };
         }
         ```
      
    • query-guide.md 21.4 KB
      
      # TanStack Query (React Query) v5
      
      Powerful asynchronous state management for React. TanStack Query makes fetching, caching, synchronizing, and updating server state in your React applications a breeze.
      
      ## When to Use This Skill
      
      - Fetching data from REST APIs or GraphQL endpoints
      - Managing server state and cache lifecycle
      - Implementing mutations (create, update, delete operations)
      - Building infinite scroll or load-more patterns
      - Handling optimistic UI updates
      - Rendering streaming/chunked data from AI or SSE endpoints
      - Integrating with tRPC v11 queryOptions pattern
      - Synchronizing data across components
      - Implementing background data refetching
      - Managing complex async state without Redux or other state managers
      
      ## Quick Start Workflow
      
      ### 1. Installation
      
      ```bash
      npm install @tanstack/react-query
      # or
      pnpm add @tanstack/react-query
      # or
      yarn add @tanstack/react-query
      ```
      
      ### 2. Setup QueryClient
      
      Wrap your application with `QueryClientProvider`:
      
      ```tsx
      import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
      
      const queryClient = new QueryClient();
      
      function App() {
        return (
          <QueryClientProvider client={queryClient}>
            <YourApp />
          </QueryClientProvider>
        );
      }
      ```
      
      ### 3. Basic Query
      
      ```tsx
      import { useQuery } from '@tanstack/react-query';
      
      function TodoList() {
        const { data, isLoading, error } = useQuery({
          queryKey: ['todos'],
          queryFn: async () => {
            const res = await fetch('https://api.example.com/todos');
            if (!res.ok) throw new Error('Network response was not ok');
            return res.json();
          },
        });
      
        if (isLoading) return <div>Loading...</div>;
        if (error) return <div>Error: {error.message}</div>;
      
        return (
          <ul>
            {data.map((todo) => (
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ul>
        );
      }
      ```
      
      ### 4. Basic Mutation
      
      ```tsx
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      
      function CreateTodo() {
        const queryClient = useQueryClient();
      
        const mutation = useMutation({
          mutationFn: async (newTodo) => {
            const res = await fetch('https://api.example.com/todos', {
              method: 'POST',
              body: JSON.stringify(newTodo),
              headers: { 'Content-Type': 'application/json' },
            });
            return res.json();
          },
          onSuccess: () => {
            // Invalidate and refetch todos
            queryClient.invalidateQueries({ queryKey: ['todos'] });
          },
        });
      
        return (
          <button onClick={() => mutation.mutate({ title: 'New Todo' })}>
            {mutation.isPending ? 'Creating...' : 'Create Todo'}
          </button>
        );
      }
      ```
      
      ## Core Concepts
      
      ### Query Keys
      
      Query keys uniquely identify queries and are used for caching. They must be arrays.
      
      ```tsx
      // Simple key
      useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
      
      // Key with variables
      useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
      
      // Hierarchical keys
      useQuery({ queryKey: ['todos', 'list', { filters, page }], queryFn: fetchTodos });
      ```
      
      **Query key matching:**
      - `['todos']` - exact match
      - `['todos', { page: 1 }]` - exact match with object
      - `{ queryKey: ['todos'] }` - matches all queries starting with 'todos'
      
      **Footgun - include every runtime dimension in the key.** If the result depends on a dimension that is *not* in the key (active network/chain, tenant, workspace, locale), switching that dimension serves cached data from the previous context. Add every input that changes the response: `['todos', { tenantId, network }]`.
      
      **Footgun - keep key inputs referentially stable.** A queryKey is compared structurally, but a *new reference produced every render* still churns downstream code that treats it as a dependency. The classic loop: a hook returns `query.data ?? []`, handing back a fresh `[]` each render; feed that into a `useEffect` dep array or another queryKey and you get infinite refetches or "Maximum update depth exceeded". Fixes: hoist a stable `const EMPTY: Item[] = []` and return `query.data ?? EMPTY`; use `select` to derive stable values; and avoid putting freshly-constructed objects/arrays (or proxy getters that return a new object per access) directly into a key or dep array.
      
      ### Query Functions
      
      Query functions must return a promise that resolves data or throws an error:
      
      ```tsx
      // Using fetch
      queryFn: async () => {
        const res = await fetch(url);
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      }
      
      // Using axios
      queryFn: () => axios.get(url).then(res => res.data)
      
      // With query key access
      queryFn: ({ queryKey }) => {
        const [_, todoId] = queryKey;
        return fetchTodo(todoId);
      }
      ```
      
      ### Important Defaults
      
      Understanding defaults is crucial for optimal usage:
      
      - **staleTime: 0** - Queries become stale immediately by default
      - **gcTime: 5 minutes** - Unused/inactive cache data remains in memory for 5 minutes
      - **retry: 3** - Failed queries retry 3 times with exponential backoff
      - **refetchOnWindowFocus: true** - Queries refetch when window regains focus
      - **refetchOnReconnect: true** - Queries refetch when network reconnects
      
      ```tsx
      // Override defaults globally
      const queryClient = new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 5, // 5 minutes
            gcTime: 1000 * 60 * 10, // 10 minutes
          },
        },
      });
      
      // Or per query
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        staleTime: 1000 * 60, // 1 minute
        retry: 5,
      });
      ```
      
      ### Query Status and Fetch Status
      
      Queries have two important states:
      
      **Query Status:**
      - `pending` - No cached data, query is executing
      - `error` - Query encountered an error
      - `success` - Query succeeded and data is available
      
      **Fetch Status:**
      - `fetching` - Query function is executing
      - `paused` - Query wants to fetch but is paused (offline)
      - `idle` - Query is not fetching
      
      ```tsx
      const { data, status, fetchStatus, isLoading, isFetching } = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
      });
      
      // isLoading = status === 'pending'
      // isFetching = fetchStatus === 'fetching'
      ```
      
      ### App-Wide Loading Indicators
      
      `useIsFetching` / `useIsMutating` return the *number* of queries currently fetching / mutations currently running across the whole cache - ideal for a global top-bar spinner without threading state through components. Both accept optional filters to scope the count.
      
      ```tsx
      import { useIsFetching, useIsMutating } from '@tanstack/react-query';
      
      function GlobalSpinner() {
        const fetching = useIsFetching();                          // all queries
        const mutating = useIsMutating({ mutationKey: ['todos'] }); // scoped
        return fetching + mutating > 0 ? <TopProgressBar /> : null;
      }
      ```
      
      ### Query Invalidation
      
      Mark queries as stale to trigger refetches:
      
      ```tsx
      const queryClient = useQueryClient();
      
      // Invalidate all todos queries
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      
      // Invalidate specific query
      queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
      
      // Invalidate and refetch immediately
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        refetchType: 'active' // only refetch active queries
      });
      ```
      
      ### Mutations
      
      Mutations are used for creating, updating, or deleting data:
      
      ```tsx
      const mutation = useMutation({
        mutationFn: (newTodo) => {
          return fetch('/api/todos', {
            method: 'POST',
            body: JSON.stringify(newTodo),
          });
        },
        onSuccess: (data, variables, context) => {
          console.log('Success!', data);
        },
        onError: (error, variables, context) => {
          console.error('Error:', error);
        },
        onSettled: (data, error, variables, context) => {
          console.log('Mutation finished');
        },
      });
      
      // Trigger mutation
      mutation.mutate({ title: 'New Todo' });
      
      // With async/await
      mutation.mutateAsync({ title: 'New Todo' })
        .then(data => console.log(data))
        .catch(error => console.error(error));
      ```
      
      **Two recurring mutation pitfalls:**
      - Mutations do **not** auto-invalidate or refetch related queries. Call `queryClient.invalidateQueries(...)` (or `setQueryData`) in `onSuccess`/`onSettled` yourself.
      - Under React StrictMode a submit handler can fire the mutation twice. Guard event handlers: `if (mutation.isPending) return;` before calling `mutate`.
      
      ### Reusable Mutation Definitions with `mutationOptions`
      
      Just as `queryOptions()` co-locates query config, the native `mutationOptions()` helper (stable since v5; companion to `queryOptions`) shares a typed mutation definition across components, `useMutation`, and `queryClient.getMutationDefaults`:
      
      ```tsx
      import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query';
      
      function createTodoOptions(queryClient: QueryClient) {
        return mutationOptions({
          mutationKey: ['todos', 'create'],
          mutationFn: (todo: { title: string }) =>
            fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) }).then(r => r.json()),
          onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
        });
      }
      
      // usage:
      const queryClient = useQueryClient();
      const mutation = useMutation(createTodoOptions(queryClient));
      ```
      
      ### Reading mutation state across components with `useMutationState`
      
      `useMutationState` reads state from the mutation cache without holding the `useMutation` instance - useful for shared pending/optimistic UI (e.g. showing in-flight items in a list the mutation does not own):
      
      ```tsx
      import { useMutationState } from '@tanstack/react-query';
      
      // All variables of currently-pending create-todo mutations
      const pendingTodos = useMutationState({
        filters: { mutationKey: ['todos', 'create'], status: 'pending' },
        select: (mutation) => mutation.state.variables as { title: string },
      });
      ```
      
      ### React Suspense Integration
      
      TanStack Query supports React Suspense with dedicated hooks:
      
      ```tsx
      import { useSuspenseQuery } from '@tanstack/react-query';
      
      function TodoList() {
        // This will suspend the component until data is ready
        const { data } = useSuspenseQuery({
          queryKey: ['todos'],
          queryFn: fetchTodos,
        });
      
        // No need for loading states - handled by Suspense boundary
        return (
          <ul>
            {data.map((todo) => (
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ul>
        );
      }
      
      // In parent component
      function App() {
        return (
          <Suspense fallback={<div>Loading todos...</div>}>
            <TodoList />
          </Suspense>
        );
      }
      ```
      
      ### Streamed Queries (Experimental)
      
      Consume `AsyncIterable` streams as query data - ideal for AI chat, SSE, and streaming responses:
      
      ```tsx
      import { useQuery, queryOptions } from '@tanstack/react-query';
      import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query';
      
      async function* fetchChatStream(sessionId: string): AsyncIterable<string> {
        const response = await fetch(`/api/chat/${sessionId}`, { method: 'POST' });
        const reader = response.body!.getReader();
        const decoder = new TextDecoder();
      
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          yield decoder.decode(value);
        }
      }
      
      function ChatMessages({ sessionId }: { sessionId: string }) {
        const { data: chunks, status, fetchStatus } = useQuery(
          queryOptions({
            queryKey: ['chat', sessionId],
            queryFn: streamedQuery({
              streamFn: () => fetchChatStream(sessionId),
              // Optional: customize how chunks accumulate
              // reducer: (acc, chunk) => [...acc, chunk],
              // initialValue: [],
              refetchMode: 'reset',  // 'reset' | 'append' | 'replace'
            }),
          })
        );
      
        // status === 'pending' until first chunk arrives
        // status === 'success' after first chunk, fetchStatus === 'fetching' until stream ends
        if (status === 'pending') return <div>Waiting for response...</div>;
      
        return (
          <div>
            {chunks?.map((chunk, i) => <span key={i}>{chunk}</span>)}
            {fetchStatus === 'fetching' && <span className="cursor" />}
          </div>
        );
      }
      ```
      
      **`refetchMode` options:**
      - `'reset'` - clear data and start fresh on refetch
      - `'append'` - keep existing chunks and add new ones
      - `'replace'` - replace data chunk-by-chunk on refetch
      
      **Note:** The API stabilized at v5.86.0. Earlier versions used `queryFn` instead of `streamFn` and `maxChunks` instead of `reducer`.
      
      ### Prefetch in Render (Experimental)
      
      Use React 19's `React.use()` with TanStack Query for "render-as-you-fetch":
      
      ```tsx
      // Enable the feature flag
      const queryClient = new QueryClient({
        defaultOptions: {
          queries: {
            experimental_prefetchInRender: true,
          },
        },
      });
      
      // Component that suspends with React.use()
      function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
        const data = React.use(query.promise); // Suspends until resolved
        return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>;
      }
      
      function App() {
        const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
        return (
          <React.Suspense fallback={<div>Loading...</div>}>
            <TodoList query={query} />
          </React.Suspense>
        );
      }
      ```
      
      **Known limitations:** queries may run twice on unsuspend, incompatible with `useQueries`, `skipToken` and `refetch()` cannot be used together.
      
      ## Advanced Topics
      
      For detailed information on advanced patterns, see the reference files:
      
      ### Infinite Queries
      
      For implementing infinite scroll and load-more patterns:
      - See `infinite-queries.md` for comprehensive guide
      - Covers `useInfiniteQuery` hook
      - Bidirectional pagination
      - `getNextPageParam` and `getPreviousPageParam`
      - Refetching and background updates
      
      ### Optimistic Updates
      
      For updating UI before server confirmation:
      - See `optimistic-updates.md` for detailed patterns
      - Optimistic mutations
      - Rollback on error
      - Context for cancellation
      - UI feedback strategies
      
      ### TypeScript Support
      
      For full type safety and inference:
      - See `query-typescript.md` for complete TypeScript guide
      - Type inference from query functions
      - Generic type parameters
      - Typing query options
      - Custom hooks with types
      - Error type narrowing
      
      ### Query Invalidation Patterns
      
      For advanced cache invalidation strategies:
      - See `query-invalidation.md`
      - Partial matching
      - Predicate functions
      - Refetch strategies
      - Query filters
      
      ### Performance Optimization
      
      For optimizing query performance:
      - See `query-performance.md`
      - Query deduplication
      - Structural sharing
      - Memory management
      - Query splitting strategies
      
      ## DevTools
      
      TanStack Query DevTools provide visual insights into query state:
      
      ```bash
      npm install @tanstack/react-query-devtools
      ```
      
      ```tsx
      import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
      
      function App() {
        return (
          <QueryClientProvider client={queryClient}>
            <YourApp />
            <ReactQueryDevtools initialIsOpen={false} />
          </QueryClientProvider>
        );
      }
      ```
      
      **DevTools features:**
      - View all queries and their states
      - Inspect query data and errors
      - Manually trigger refetches
      - Invalidate queries
      - Monitor cache lifecycle
      - Visual indicator for `staleTime: Infinity` ("static") queries (v5.80.0+)
      
      ## Common Patterns
      
      ### Dependent Queries
      
      Run queries in sequence when one depends on another:
      
      ```tsx
      // First query
      const { data: user } = useQuery({
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId),
      });
      
      // Second query depends on first
      const { data: projects } = useQuery({
        queryKey: ['projects', user?.id],
        queryFn: () => fetchProjects(user.id),
        enabled: !!user?.id, // Only run when user.id is available
      });
      ```
      
      ### Parallel Queries
      
      Multiple independent queries in one component:
      
      ```tsx
      function Dashboard() {
        const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
        const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
        const stats = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
      
        if (users.isLoading || posts.isLoading || stats.isLoading) {
          return <div>Loading...</div>;
        }
      
        // All queries succeeded
        return <DashboardView users={users.data} posts={posts.data} stats={stats.data} />;
      }
      ```
      
      ### Dynamic Parallel Queries
      
      Use `useQueries` for dynamic number of queries:
      
      ```tsx
      import { useQueries } from '@tanstack/react-query';
      
      function TodoLists({ listIds }) {
        const results = useQueries({
          queries: listIds.map((id) => ({
            queryKey: ['list', id],
            queryFn: () => fetchList(id),
          })),
        });
      
        const isLoading = results.some(result => result.isLoading);
        const data = results.map(result => result.data);
      
        return <Lists data={data} />;
      }
      ```
      
      ### Prefetching
      
      Prefetch data before it's needed:
      
      ```tsx
      const queryClient = useQueryClient();
      
      // Prefetch on hover
      function TodoListLink({ id }) {
        const prefetch = () => {
          queryClient.prefetchQuery({
            queryKey: ['todo', id],
            queryFn: () => fetchTodo(id),
            staleTime: 1000 * 60 * 5, // Cache for 5 minutes
          });
        };
      
        return (
          <Link to={`/todo/${id}`} onMouseEnter={prefetch}>
            View Todo
          </Link>
        );
      }
      ```
      
      ### Initial Data
      
      Provide initial data to avoid loading states:
      
      ```tsx
      function TodoDetail({ todoId, initialTodo }) {
        const { data } = useQuery({
          queryKey: ['todo', todoId],
          queryFn: () => fetchTodo(todoId),
          initialData: initialTodo, // Use this data immediately
          staleTime: 1000 * 60, // Consider fresh for 1 minute
        });
      
        return <div>{data.title}</div>;
      }
      ```
      
      ### Placeholder Data
      
      Show placeholder while loading:
      
      ```tsx
      const { data, isPlaceholderData } = useQuery({
        queryKey: ['todos', page],
        queryFn: () => fetchTodos(page),
        placeholderData: (previousData) => previousData, // Keep previous data while loading
      });
      
      // Or use static placeholder
      const { data } = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        placeholderData: { items: [], total: 0 },
      });
      
      // TypeScript: isPlaceholderData now narrows data type (v5.65.0+)
      // When isPlaceholderData is true, data is typed as the placeholder type
      ```
      
      ### tRPC v11 Integration
      
      tRPC v11 exposes `queryOptions` and `mutationOptions` directly, removing the need for custom hook wrappers:
      
      ```tsx
      import { useTRPC } from '@trpc/tanstack-react-query';
      import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
      
      function TodoList() {
        const trpc = useTRPC();
        const queryClient = useQueryClient();
      
        // Direct queryOptions pattern (replaces trpc.todo.list.useQuery())
        const { data } = useQuery(trpc.todo.list.queryOptions());
      
        const createTodo = useMutation(
          trpc.todo.create.mutationOptions({
            onSuccess: () => {
              queryClient.invalidateQueries(trpc.todo.list.queryOptions());
            },
          })
        );
      
        // Prefetching also works
        queryClient.prefetchQuery(trpc.todo.list.queryOptions());
      }
      ```
      
      Requires `@tanstack/react-query@5.62.8+` and `@trpc/tanstack-react-query`.
      
      ## Error Handling
      
      ### Query Errors
      
      ```tsx
      const { error, isError } = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        retry: 3,
        retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      });
      
      if (isError) {
        return <div>Error: {error.message}</div>;
      }
      ```
      
      ### Global Error Handling
      
      Use `QueryCache` and `MutationCache` callbacks for global error handling:
      
      ```tsx
      import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
      
      const queryClient = new QueryClient({
        queryCache: new QueryCache({
          onError: (error, query) => {
            console.error(`Query ${query.queryKey} failed:`, error);
            // Show toast notification, etc.
          },
        }),
        mutationCache: new MutationCache({
          onError: (error, _variables, _context, mutation) => {
            console.error('Mutation failed:', error);
          },
        }),
      });
      ```
      
      ### Error Boundaries
      
      Combine with React Error Boundaries:
      
      ```tsx
      import { useQuery } from '@tanstack/react-query';
      import { ErrorBoundary } from 'react-error-boundary';
      
      function TodoList() {
        const { data } = useQuery({
          queryKey: ['todos'],
          queryFn: fetchTodos,
          throwOnError: true, // Throw errors to error boundary
        });
      
        return <div>{/* render data */}</div>;
      }
      
      function App() {
        return (
          <ErrorBoundary fallback={<div>Something went wrong</div>}>
            <TodoList />
          </ErrorBoundary>
        );
      }
      ```
      
      ## Best Practices
      
      1. **Use Query Keys Wisely**
         - Structure keys hierarchically: `['todos', 'list', { filters }]`
         - Include all variables in the key
         - Keep keys consistent across your app
      
      2. **Set Appropriate staleTime**
         - Static data: `staleTime: Infinity`
         - Frequently changing: `staleTime: 0` (default)
         - Moderately changing: `staleTime: 1000 * 60 * 5` (5 minutes)
      
      3. **Handle Loading and Error States**
         - Always check `isLoading` and `error`
         - Provide meaningful loading indicators
         - Show user-friendly error messages
      
      4. **Optimize Refetching**
         - Disable unnecessary refetches with `refetchOnWindowFocus: false`
         - Use `staleTime` to reduce refetches
         - Consider using `refetchInterval` for polling
      
      5. **Invalidate Efficiently**
         - Invalidate specific queries, not all queries
         - Use query key prefixes for related queries
         - Combine with optimistic updates for better UX
      
      6. **Use TypeScript**
         - Type your query functions for type inference
         - Use generic type parameters when needed
         - Enable strict type checking
      
      7. **Leverage DevTools**
         - Install DevTools in development
         - Monitor query behavior
         - Debug cache issues
      
      ## Resources
      
      - **Official Documentation**: https://tanstack.com/query/latest/docs/framework/react/overview
      - **GitHub Repository**: https://github.com/TanStack/query
      - **Examples**: https://tanstack.com/query/latest/docs/framework/react/examples
      - **Community**: https://discord.gg/tanstack
      - **TypeScript Guide**: https://tanstack.com/query/latest/docs/framework/react/typescript
      
      ## Migration from v4
      
      If you're upgrading from React Query v4:
      
      - `cacheTime` renamed to `gcTime`
      - `useInfiniteQuery` pageParam changes
      - New `useSuspenseQuery` hooks
      - Improved TypeScript inference
      - v4.42.0 added React 19 support for teams not yet migrated to v5
      - See official migration guide: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5
      
    • query-invalidation.md 16.6 KB
      # Query Invalidation
      
      Query invalidation is the process of marking queries as stale and potentially refetching them. This is essential for keeping your cache in sync with server state after mutations.
      
      ## Basic Invalidation
      
      ```tsx
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      
      function CreateTodo() {
        const queryClient = useQueryClient();
      
        const mutation = useMutation({
          mutationFn: (newTodo) => {
            return fetch('/api/todos', {
              method: 'POST',
              body: JSON.stringify(newTodo),
            }).then(res => res.json());
          },
          onSuccess: () => {
            // Mark todos queries as stale and refetch
            queryClient.invalidateQueries({ queryKey: ['todos'] });
          },
        });
      
        return (
          <button onClick={() => mutation.mutate({ title: 'New Todo' })}>
            Create Todo
          </button>
        );
      }
      ```
      
      ## Invalidation Methods
      
      ### invalidateQueries
      
      Marks queries as stale and triggers refetch of active queries:
      
      ```tsx
      // Invalidate all queries
      queryClient.invalidateQueries();
      
      // Invalidate specific query
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      
      // Invalidate query with exact match
      queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true });
      
      // Invalidate and wait for refetch
      await queryClient.invalidateQueries({ queryKey: ['todos'] });
      ```
      
      ### refetchQueries
      
      Directly refetch queries without marking as stale first:
      
      ```tsx
      // Refetch all queries
      queryClient.refetchQueries();
      
      // Refetch specific queries
      queryClient.refetchQueries({ queryKey: ['todos'] });
      
      // Refetch only active queries
      queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
      
      // Refetch only inactive queries
      queryClient.refetchQueries({ queryKey: ['todos'], type: 'inactive' });
      ```
      
      ### resetQueries
      
      Reset queries to their initial state:
      
      ```tsx
      // Reset and refetch
      queryClient.resetQueries({ queryKey: ['todos'] });
      
      // Reset specific query
      queryClient.resetQueries({ queryKey: ['todo', todoId] });
      ```
      
      ## Query Key Matching
      
      ### Prefix Matching
      
      By default, invalidateQueries uses prefix matching:
      
      ```tsx
      // This query
      useQuery({ queryKey: ['todos', 'list', { page: 1 }], queryFn: fetchTodos });
      
      // Is invalidated by any of these:
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
      queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }] });
      
      // But NOT by these:
      queryClient.invalidateQueries({ queryKey: ['todos', 'detail'] });
      queryClient.invalidateQueries({ queryKey: ['users'] });
      ```
      
      ### Exact Matching
      
      Use `exact: true` for precise matching:
      
      ```tsx
      // Only invalidate this exact query key
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list', { page: 1 }],
        exact: true,
      });
      
      // This would invalidate:
      useQuery({ queryKey: ['todos', 'list', { page: 1 }], ... });
      
      // But NOT these:
      useQuery({ queryKey: ['todos', 'list', { page: 2 }], ... });
      useQuery({ queryKey: ['todos', 'list'], ... });
      useQuery({ queryKey: ['todos'], ... });
      ```
      
      ### Predicate Functions
      
      Use custom matching logic:
      
      ```tsx
      // Invalidate all todos queries except detail queries
      queryClient.invalidateQueries({
        predicate: (query) => {
          return query.queryKey[0] === 'todos' && query.queryKey[1] !== 'detail';
        },
      });
      
      // Invalidate stale queries only
      queryClient.invalidateQueries({
        predicate: (query) => {
          return query.state.isInvalidated;
        },
      });
      
      // Invalidate based on query data
      queryClient.invalidateQueries({
        predicate: (query) => {
          const data = query.state.data as Todo[] | undefined;
          return data?.some((todo) => todo.userId === targetUserId) ?? false;
        },
      });
      ```
      
      ## Invalidation Timing
      
      ### Immediate Invalidation
      
      Invalidate and refetch immediately:
      
      ```tsx
      const mutation = useMutation({
        mutationFn: createTodo,
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ### Delayed Invalidation
      
      Wait for mutation to settle:
      
      ```tsx
      const mutation = useMutation({
        mutationFn: createTodo,
        onSettled: () => {
          // Runs after success or error
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ### Conditional Invalidation
      
      Only invalidate under certain conditions:
      
      ```tsx
      const mutation = useMutation({
        mutationFn: updateTodo,
        onSuccess: (data, variables) => {
          if (data.isPublished) {
            // Only invalidate if todo was published
            queryClient.invalidateQueries({ queryKey: ['todos', 'published'] });
          }
        },
      });
      ```
      
      ## Refetch Strategies
      
      ### Refetch Active Queries Only
      
      ```tsx
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        refetchType: 'active', // Only refetch active queries (default)
      });
      ```
      
      ### Refetch All Queries
      
      ```tsx
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        refetchType: 'all', // Refetch both active and inactive queries
      });
      ```
      
      ### Don't Refetch
      
      ```tsx
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        refetchType: 'none', // Only mark as stale, don't refetch
      });
      ```
      
      ## Invalidation Patterns
      
      ### After Create
      
      ```tsx
      const createTodo = useMutation({
        mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', ... }),
        onSuccess: () => {
          // Invalidate list queries to show new item
          queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
        },
      });
      ```
      
      ### After Update
      
      ```tsx
      const updateTodo = useMutation({
        mutationFn: ({ id, updates }) => fetch(`/api/todos/${id}`, { method: 'PATCH', ... }),
        onSuccess: (data, { id }) => {
          // Invalidate specific item
          queryClient.invalidateQueries({ queryKey: ['todo', id] });
          // Invalidate list in case item moved categories, etc.
          queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
        },
      });
      ```
      
      ### After Delete
      
      ```tsx
      const deleteTodo = useMutation({
        mutationFn: (id) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
        onSuccess: (_, id) => {
          // Remove specific item from cache
          queryClient.removeQueries({ queryKey: ['todo', id] });
          // Invalidate lists
          queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
        },
      });
      ```
      
      ### Bulk Operations
      
      ```tsx
      const markAllDone = useMutation({
        mutationFn: () => fetch('/api/todos/mark-all-done', { method: 'POST' }),
        onSuccess: () => {
          // Invalidate all todo-related queries
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
      ```
      
      ## Related Query Invalidation
      
      ### Update Multiple Related Queries
      
      ```tsx
      const updateUser = useMutation({
        mutationFn: ({ userId, updates }) => updateUserApi(userId, updates),
        onSuccess: (data, { userId }) => {
          // Invalidate user detail
          queryClient.invalidateQueries({ queryKey: ['user', userId] });
          // Invalidate user list
          queryClient.invalidateQueries({ queryKey: ['users'] });
          // Invalidate user's posts
          queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] });
          // Invalidate user's comments
          queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] });
        },
      });
      ```
      
      ### Hierarchical Invalidation
      
      ```tsx
      // Query key structure:
      // ['todos'] - all todos
      // ['todos', 'list'] - todo lists
      // ['todos', 'list', filters] - filtered lists
      // ['todos', 'detail'] - todo details
      // ['todos', 'detail', id] - specific todo
      
      const updateTodo = useMutation({
        mutationFn: updateTodoApi,
        onSuccess: (data, { id }) => {
          // Invalidate specific todo detail
          queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] });
          // Invalidate all list queries (they might show this todo)
          queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
        },
      });
      ```
      
      ## Invalidation with Infinite Queries
      
      ### Invalidate All Pages
      
      ```tsx
      const createPost = useMutation({
        mutationFn: (newPost) => createPostApi(newPost),
        onSuccess: () => {
          // Refetches all pages
          queryClient.invalidateQueries({ queryKey: ['posts'] });
        },
      });
      ```
      
      ### Invalidate Specific Pages
      
      ```tsx
      queryClient.invalidateQueries({
        queryKey: ['posts'],
        refetchPage: (page, index) => {
          // Only refetch first page
          return index === 0;
        },
      });
      ```
      
      ### Selective Page Refetch
      
      ```tsx
      const updatePost = useMutation({
        mutationFn: ({ id, updates }) => updatePostApi(id, updates),
        onSuccess: (data, { id }) => {
          queryClient.invalidateQueries({
            queryKey: ['posts'],
            refetchPage: (page, index) => {
              // Only refetch pages containing this post
              return page.posts.some((post) => post.id === id);
            },
          });
        },
      });
      ```
      
      ## Advanced Invalidation
      
      ### Cascading Invalidation
      
      ```tsx
      const deleteProject = useMutation({
        mutationFn: (projectId) => deleteProjectApi(projectId),
        onSuccess: async (_, projectId) => {
          // Step 1: Remove project from cache
          queryClient.removeQueries({ queryKey: ['project', projectId] });
      
          // Step 2: Invalidate project list
          await queryClient.invalidateQueries({ queryKey: ['projects'] });
      
          // Step 3: Invalidate related resources
          await queryClient.invalidateQueries({ queryKey: ['tasks', 'project', projectId] });
          await queryClient.invalidateQueries({ queryKey: ['members', 'project', projectId] });
      
          // Step 4: Invalidate summary/stats
          await queryClient.invalidateQueries({ queryKey: ['stats'] });
        },
      });
      ```
      
      ### Debounced Invalidation
      
      For frequent updates, debounce invalidation:
      
      ```tsx
      import { useDebouncedCallback } from 'use-debounce';
      
      function SearchableList() {
        const queryClient = useQueryClient();
      
        const debouncedInvalidate = useDebouncedCallback(() => {
          queryClient.invalidateQueries({ queryKey: ['search-results'] });
        }, 500);
      
        const updateFilters = (newFilters) => {
          setFilters(newFilters);
          debouncedInvalidate();
        };
      
        return <FilterPanel onChange={updateFilters} />;
      }
      ```
      
      ### Throttled Invalidation
      
      ```tsx
      import { throttle } from 'lodash';
      
      const throttledInvalidate = throttle(() => {
        queryClient.invalidateQueries({ queryKey: ['live-data'] });
      }, 1000);
      
      // In a websocket listener
      socket.on('update', () => {
        throttledInvalidate();
      });
      ```
      
      ## Query Filters
      
      Use query filters for more complex matching. v5.90.8+ supports partial query keys and preserves `readonly` from `as const` assertions:
      
      ```tsx
      import { QueryFilters } from '@tanstack/react-query';
      
      const filters: QueryFilters = {
        queryKey: ['todos'],
        type: 'active',        // 'active' | 'inactive' | 'all'
        stale: true,           // Only stale queries
        exact: false,          // Prefix matching (supports partial keys)
        predicate: (query) => {
          // Custom logic
          return query.state.dataUpdatedAt > Date.now() - 60000;
        },
      };
      
      queryClient.invalidateQueries(filters);
      
      // Partial key matching with as const (v5.90.8+)
      const todoKeys = { all: ['todos'] as const };
      queryClient.invalidateQueries({ queryKey: todoKeys.all }); // readonly works
      ```
      
      ### Filter by State
      
      ```tsx
      // Only invalidate stale queries
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        stale: true,
      });
      
      // Only invalidate fetching queries
      queryClient.invalidateQueries({
        predicate: (query) => query.state.fetchStatus === 'fetching',
      });
      ```
      
      ### Filter by Type
      
      ```tsx
      // Only active queries (currently mounted)
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        type: 'active',
      });
      
      // Only inactive queries (not mounted)
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        type: 'inactive',
      });
      
      // All queries
      queryClient.invalidateQueries({
        queryKey: ['todos'],
        type: 'all',
      });
      ```
      
      ## Performance Considerations
      
      ### Batch Invalidations
      
      ```tsx
      // ❌ Multiple separate invalidations
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      queryClient.invalidateQueries({ queryKey: ['users'] });
      queryClient.invalidateQueries({ queryKey: ['projects'] });
      
      // ✅ Batch with predicate
      queryClient.invalidateQueries({
        predicate: (query) => {
          const key = query.queryKey[0];
          return key === 'todos' || key === 'users' || key === 'projects';
        },
      });
      ```
      
      ### Smart Invalidation
      
      Only invalidate what's needed:
      
      ```tsx
      const updateTodo = useMutation({
        mutationFn: updateTodoApi,
        onSuccess: (data, { id, updates }) => {
          // If only title changed, no need to invalidate lists
          if (Object.keys(updates).length === 1 && 'title' in updates) {
            queryClient.invalidateQueries({ queryKey: ['todo', id], exact: true });
          } else {
            // Status/category changed, invalidate lists too
            queryClient.invalidateQueries({ queryKey: ['todo', id] });
            queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
          }
        },
      });
      ```
      
      ### Prevent Over-Invalidation
      
      ```tsx
      // ❌ Too broad - invalidates everything
      queryClient.invalidateQueries();
      
      // ❌ Still too broad - invalidates all todo queries
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      
      // ✅ Specific - only invalidates affected queries
      queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
      queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
      ```
      
      ## Alternatives to Invalidation
      
      Sometimes you don't need invalidation:
      
      ### Direct Cache Update
      
      ```tsx
      const toggleTodo = useMutation({
        mutationFn: (todoId) => toggleTodoApi(todoId),
        onSuccess: (data, todoId) => {
          // Directly update cache instead of invalidating
          queryClient.setQueryData(['todo', todoId], data);
          queryClient.setQueryData(['todos'], (old) =>
            old?.map((todo) => (todo.id === todoId ? data : todo))
          );
        },
      });
      ```
      
      ### Optimistic Updates
      
      ```tsx
      const updateTodo = useMutation({
        mutationFn: updateTodoApi,
        onMutate: async ({ id, updates }) => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          const previous = queryClient.getQueryData(['todos']);
      
          // Update cache optimistically
          queryClient.setQueryData(['todos'], (old) =>
            old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
          );
      
          return { previous };
        },
        onError: (err, vars, context) => {
          queryClient.setQueryData(['todos'], context.previous);
        },
        // No need for invalidation if optimistic update is accurate
      });
      ```
      
      ### Polling
      
      ```tsx
      // Instead of manual invalidation, use automatic refetching
      useQuery({
        queryKey: ['live-data'],
        queryFn: fetchLiveData,
        refetchInterval: 5000, // Auto-refetch every 5 seconds
      });
      ```
      
      ### Known Issues (Fixed)
      
      These bugs can manifest as invalidation-related surprises. They're fixed in latest versions but worth knowing:
      
      - **`combine` stable reference bug (fixed v5.90.19):** When passing a stable (non-inline) `combine` function to `useQueries`, it was not called with correct parameters when queries changed dynamically. Workaround: pass `combine` inline until fix is applied.
      - **`useSuspenseQueries` duplicate keys (fixed v5.90.11):** Passing duplicate `queryKeys` to `useSuspenseQueries` caused infinite render loops instead of an error.
      - **SSR dehydration (fixed v5.90.3):** Unhandled promise rejections during de/rehydration of pending queries.
      
      ### Pitfall: invalidate-then-navigate shows stale data
      
      `invalidateQueries` marks the query stale and kicks off a **background** refetch - it does not block. If you navigate (or render the destination) immediately after invalidating, the new view paints with the old cached data before the refetch lands (e.g. a just-closed item still appears in the list). Two fixes:
      
      ```tsx
      // Option A: optimistically update the cache so the change is visible instantly
      queryClient.setQueryData(['todos'], (old) => old?.filter((t) => t.id !== id));
      
      // Option B: await the refetch before navigating
      await queryClient.invalidateQueries({ queryKey: ['todos'] });
      navigate({ to: '/todos' });
      ```
      
      ## Best Practices
      
      1. **Be Specific with Query Keys**
         ```tsx
         // ✅ Good - specific invalidation
         queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
      
         // ❌ Bad - too broad
         queryClient.invalidateQueries({ queryKey: ['todos'] });
         ```
      
      2. **Use Exact Matching When Appropriate**
         ```tsx
         queryClient.invalidateQueries({
           queryKey: ['todo', todoId],
           exact: true, // Only this specific todo
         });
         ```
      
      3. **Invalidate in onSuccess for Success-Only**
         ```tsx
         onSuccess: () => {
           queryClient.invalidateQueries({ queryKey: ['todos'] });
         }
         ```
      
      4. **Invalidate in onSettled for Always**
         ```tsx
         onSettled: () => {
           queryClient.invalidateQueries({ queryKey: ['todos'] });
         }
         ```
      
      5. **Consider Alternatives**
         - Direct cache updates for simple changes
         - Optimistic updates for better UX
         - Polling for real-time data
      
      6. **Batch Related Invalidations**
         ```tsx
         await Promise.all([
           queryClient.invalidateQueries({ queryKey: ['todos'] }),
           queryClient.invalidateQueries({ queryKey: ['stats'] }),
         ]);
         ```
      
      7. **Use Predicate Functions for Complex Logic**
         ```tsx
         queryClient.invalidateQueries({
           predicate: (query) => {
             // Custom matching logic
             return shouldInvalidate(query);
           },
         });
         ```
      
      8. **Monitor Invalidation Performance**
         - Use React Query DevTools
         - Check for unnecessary refetches
         - Optimize query key structure
      
    • query-performance.md 18.1 KB
      # Performance Optimization
      
      Optimize TanStack Query for better performance, reduced network requests, and improved user experience.
      
      ## Query Configuration
      
      ### staleTime
      
      Control how long data is considered fresh:
      
      ```tsx
      // ❌ Default - data stale immediately
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        staleTime: 0, // default
      });
      
      // ✅ Optimized - data fresh for 5 minutes
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        staleTime: 1000 * 60 * 5, // 5 minutes
      });
      
      // ✅ Static data - never stale
      useQuery({
        queryKey: ['config'],
        queryFn: fetchConfig,
        staleTime: Infinity,
      });
      ```
      
      **`staleTime` is not persistence.** It only suppresses refetches while data is fresh *and the query is mounted*. The cache is in-memory and is wiped on a full page reload - `staleTime` does nothing across reloads. Also note a low `staleTime` (the default is `0`) makes every navigation back to a route refetch; raise it for data that does not change per-visit. For cross-reload persistence, wire up the persist-client plugin (see below).
      
      ### Cross-reload persistence
      
      The cache is in-memory only, so every full reload refetches from scratch (spinners, empty states). For React, persist it with `PersistQueryClientProvider` from `@tanstack/react-query-persist-client` plus a storage persister:
      
      ```tsx
      import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
      import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
      
      const queryClient = new QueryClient({
        defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24 } }, // gcTime >= maxAge
      })
      
      const persister = createSyncStoragePersister({ storage: window.localStorage })
      
      <PersistQueryClientProvider
        client={queryClient}
        persistOptions={{
          persister,
          maxAge: 1000 * 60 * 60 * 24,         // discard restored cache older than this
          buster: import.meta.env.VITE_BUILD_ID, // bump to invalidate on cache-shape changes
          dehydrateOptions: {
            // Never persist credential-bearing or per-identity queries to storage.
            shouldDehydrateQuery: (q) => q.queryKey[0] !== 'session',
          },
        }}
      >
        <App />
      </PersistQueryClientProvider>
      ```
      
      Footguns:
      - **`gcTime` must be >= `maxAge`** or garbage collection discards the cache before the persister can restore it (defaults: `gcTime` 5min, `maxAge` 24h).
      - **Set `buster`** to a build/schema version. Without it, a changed cache shape silently hydrates a stale snapshot into new code.
      - **Exclude credential/identity queries** via `shouldDehydrateQuery` - tokens, API keys, and per-user data should not land in `localStorage`.
      - **SSR**: persistence is client-only; guard `window` access (`typeof window !== 'undefined'`) so it does not run on the server.
      - **Version-match** the persist-client package to your installed `@tanstack/react-query` version (TanStack companion packages must share the same version).
      
      ### gcTime (formerly cacheTime)
      
      Control how long unused data stays in cache:
      
      ```tsx
      // Default - 5 minutes
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        gcTime: 1000 * 60 * 5, // default
      });
      
      // Extended cache for frequently accessed data
      useQuery({
        queryKey: ['user-profile'],
        queryFn: fetchUserProfile,
        gcTime: 1000 * 60 * 30, // 30 minutes
      });
      
      // Immediate cleanup for sensitive data
      useQuery({
        queryKey: ['payment-info'],
        queryFn: fetchPaymentInfo,
        gcTime: 0, // Remove immediately when unused
      });
      ```
      
      ### Disable Unnecessary Refetching
      
      ```tsx
      // Disable all automatic refetching
      useQuery({
        queryKey: ['static-data'],
        queryFn: fetchStaticData,
        refetchOnWindowFocus: false,
        refetchOnReconnect: false,
        refetchOnMount: false,
      });
      
      // Global defaults
      const queryClient = new QueryClient({
        defaultOptions: {
          queries: {
            refetchOnWindowFocus: false,
            refetchOnReconnect: false,
          },
        },
      });
      ```
      
      ## Query Deduplication
      
      TanStack Query automatically deduplicates identical requests:
      
      ```tsx
      // These three components all request the same data
      function Component1() {
        useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
      }
      
      function Component2() {
        useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
      }
      
      function Component3() {
        useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
      }
      
      // Result: Only ONE network request is made
      // All three components share the same cached data
      ```
      
      ## Structural Sharing
      
      TanStack Query preserves referential equality when data hasn't changed:
      
      ```tsx
      const { data } = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        // Structural sharing is enabled by default
        structuralSharing: true,
      });
      
      // If server returns identical data, React won't re-render
      // because data reference hasn't changed
      ```
      
      Disable for very large datasets:
      
      ```tsx
      useQuery({
        queryKey: ['large-dataset'],
        queryFn: fetchLargeDataset,
        structuralSharing: false, // Skip structural sharing for performance
      });
      ```
      
      **Note:** v5.85.7 improved the internal `replaceEqualDeep` function to use `Set` for O(1) lookups instead of `Array.indexOf`, improving performance for queries returning large arrays.
      
      ## Prefetching
      
      Load data before it's needed:
      
      ### Hover Prefetch
      
      ```tsx
      const queryClient = useQueryClient();
      
      function TodoListItem({ todo }) {
        const prefetchTodo = () => {
          queryClient.prefetchQuery({
            queryKey: ['todo', todo.id],
            queryFn: () => fetchTodo(todo.id),
            staleTime: 1000 * 60 * 5,
          });
        };
      
        return (
          <Link
            to={`/todo/${todo.id}`}
            onMouseEnter={prefetchTodo}
            onFocus={prefetchTodo}
          >
            {todo.title}
          </Link>
        );
      }
      ```
      
      ### Route-Based Prefetch
      
      ```tsx
      // In router loader or component
      async function todoLoader({ params }) {
        await queryClient.prefetchQuery({
          queryKey: ['todo', params.id],
          queryFn: () => fetchTodo(params.id),
        });
      }
      
      // Or in a parent component
      function TodoLayout() {
        const navigate = useNavigate();
      
        useEffect(() => {
          // Prefetch common routes
          queryClient.prefetchQuery({
            queryKey: ['todos'],
            queryFn: fetchTodos,
          });
        }, []);
      
        return <Outlet />;
      }
      ```
      
      ### Predictive Prefetch
      
      ```tsx
      function PaginatedList({ page }) {
        const { data } = useQuery({
          queryKey: ['items', page],
          queryFn: () => fetchItems(page),
        });
      
        // Prefetch next page
        useEffect(() => {
          if (page < totalPages) {
            queryClient.prefetchQuery({
              queryKey: ['items', page + 1],
              queryFn: () => fetchItems(page + 1),
            });
          }
        }, [page]);
      
        return <div>{/* render items */}</div>;
      }
      ```
      
      ### Prefetch During Render (`usePrefetchQuery`)
      
      The imperative `queryClient.prefetchQuery` above runs in an effect or event handler. `usePrefetchQuery` / `usePrefetchInfiniteQuery` prefetch during the render phase instead - use them to warm a child's data in the parent that renders it (typically alongside `useSuspenseQuery` in the child), so the request starts before the child mounts. They return nothing and never suspend the caller.
      
      ```tsx
      import { usePrefetchQuery, useSuspenseQuery } from '@tanstack/react-query';
      
      function Parent() {
        // Kicks off the fetch during render; child reads it from cache
        usePrefetchQuery(commentsQueryOptions);
        return (
          <Suspense fallback={<Spinner />}>
            <Comments />
          </Suspense>
        );
      }
      
      function Comments() {
        const { data } = useSuspenseQuery(commentsQueryOptions);
        return <CommentList comments={data} />;
      }
      ```
      
      ## Data Transformation
      
      ### Use select for Transformation
      
      ```tsx
      // ❌ Transform in component - runs on every render
      function TodoList() {
        const { data } = useQuery({
          queryKey: ['todos'],
          queryFn: fetchTodos,
        });
      
        const completedTodos = data?.filter(todo => todo.completed);
        return <div>{completedTodos?.map(/* ... */)}</div>;
      }
      
      // ✅ Transform with select - memoized automatically
      function TodoList() {
        const { data: completedTodos } = useQuery({
          queryKey: ['todos'],
          queryFn: fetchTodos,
          select: (todos) => todos.filter(todo => todo.completed),
        });
      
        return <div>{completedTodos?.map(/* ... */)}</div>;
      }
      ```
      
      ### Select is Memoized
      
      ```tsx
      // select function only runs when data changes
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        select: (todos) => {
          console.log('Transforming...'); // Only logs when data changes
          return todos.map(todo => ({
            ...todo,
            displayName: `${todo.id}: ${todo.title}`,
          }));
        },
      });
      ```
      
      ## Pagination Optimization
      
      ### Offset Pagination
      
      ```tsx
      function PaginatedTodos() {
        const [page, setPage] = useState(1);
      
        const { data } = useQuery({
          queryKey: ['todos', page],
          queryFn: () => fetchTodos(page),
          staleTime: 1000 * 60 * 5, // Keep pages fresh
          placeholderData: (previousData) => previousData, // Keep previous data while loading
        });
      
        return (
          <div>
            {data?.items.map(todo => <TodoItem key={todo.id} todo={todo} />)}
            <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
              Previous
            </button>
            <button onClick={() => setPage(p => p + 1)}>
              Next
            </button>
          </div>
        );
      }
      ```
      
      ### Infinite Queries with Windowing
      
      For very long lists, use virtual scrolling:
      
      ```tsx
      import { useInfiniteQuery } from '@tanstack/react-query';
      import { useVirtualizer } from '@tanstack/react-virtual';
      
      function VirtualizedInfiniteList() {
        const {
          data,
          fetchNextPage,
          hasNextPage,
        } = useInfiniteQuery({
          queryKey: ['items'],
          queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
          initialPageParam: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor,
        });
      
        const allItems = data?.pages.flatMap(page => page.items) ?? [];
        const parentRef = useRef(null);
      
        const virtualizer = useVirtualizer({
          count: hasNextPage ? allItems.length + 1 : allItems.length,
          getScrollElement: () => parentRef.current,
          estimateSize: () => 100,
        });
      
        useEffect(() => {
          const lastItem = virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1];
      
          if (!lastItem) return;
      
          if (lastItem.index >= allItems.length - 1 && hasNextPage) {
            fetchNextPage();
          }
        }, [hasNextPage, fetchNextPage, allItems.length, virtualizer.getVirtualItems()]);
      
        return (
          <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
            <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
              {virtualizer.getVirtualItems().map((virtualRow) => (
                <div
                  key={virtualRow.index}
                  style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    width: '100%',
                    height: `${virtualRow.size}px`,
                    transform: `translateY(${virtualRow.start}px)`,
                  }}
                >
                  {allItems[virtualRow.index] ? (
                    <Item item={allItems[virtualRow.index]} />
                  ) : (
                    'Loading...'
                  )}
                </div>
              ))}
            </div>
          </div>
        );
      }
      ```
      
      ## Parallel Queries Optimization
      
      ### Using useQueries
      
      ```tsx
      // ❌ Sequential queries
      async function fetchAllData() {
        const users = await fetchUsers();
        const posts = await fetchPosts();
        const comments = await fetchComments();
        return { users, posts, comments };
      }
      
      // ✅ Parallel queries
      function Dashboard() {
        const results = useQueries({
          queries: [
            { queryKey: ['users'], queryFn: fetchUsers },
            { queryKey: ['posts'], queryFn: fetchPosts },
            { queryKey: ['comments'], queryFn: fetchComments },
          ],
        });
      
        const [users, posts, comments] = results;
        const isLoading = results.some(r => r.isLoading);
      
        return <div>{/* render */}</div>;
      }
      ```
      
      ### Dynamic Parallel Queries
      
      ```tsx
      function UserPosts({ userIds }) {
        const queries = useQueries({
          queries: userIds.map(id => ({
            queryKey: ['user-posts', id],
            queryFn: () => fetchUserPosts(id),
            staleTime: 1000 * 60 * 5,
          })),
        });
      
        return <div>{/* render */}</div>;
      }
      ```
      
      ## Memory Management
      
      ### Limit Cache Size
      
      ```tsx
      const queryClient = new QueryClient({
        defaultOptions: {
          queries: {
            gcTime: 1000 * 60 * 5, // 5 minutes
          },
        },
      });
      
      // Manually clear old queries
      queryClient.clear(); // Clear all cache
      
      // Remove specific queries
      queryClient.removeQueries({ queryKey: ['old-data'] });
      ```
      
      ### Remove Queries on Unmount
      
      ```tsx
      function ExpensiveComponent() {
        const { data } = useQuery({
          queryKey: ['expensive-data'],
          queryFn: fetchExpensiveData,
          gcTime: 0, // Remove immediately when component unmounts
        });
      
        return <div>{/* render */}</div>;
      }
      ```
      
      ## Network Optimization
      
      ### Batch Requests
      
      If your API supports batching:
      
      ```tsx
      // Collect query keys and batch them
      const batchedQueryFn = async (keys) => {
        const ids = keys.map(key => key[1]);
        const results = await fetch(`/api/items?ids=${ids.join(',')}`);
        return results.json();
      };
      
      // Use in queries
      useQuery({
        queryKey: ['item', itemId],
        queryFn: () => batchedQueryFn([['item', itemId]]),
      });
      ```
      
      ### Request Cancellation
      
      ```tsx
      useQuery({
        queryKey: ['search', searchTerm],
        queryFn: async ({ signal }) => {
          // AbortSignal automatically provided
          const res = await fetch(`/api/search?q=${searchTerm}`, { signal });
          return res.json();
        },
      });
      
      // When searchTerm changes, previous request is cancelled
      ```
      
      ### Retry Configuration
      
      ```tsx
      // ❌ Retry immediately 3 times
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        retry: 3,
      });
      
      // ✅ Exponential backoff
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        retry: 3,
        retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      });
      ```
      
      ### Network Mode
      
      `networkMode` controls how queries and mutations behave without a network connection:
      
      ```tsx
      useQuery({ queryKey: ['todos'], queryFn: fetchTodos, networkMode: 'online' });
      ```
      
      - `'online'` (default) - only fetches when online; offline queries are paused (status stays as-is, `fetchStatus: 'paused'`).
      - `'always'` - always runs the `queryFn`, ignoring online status (use for cache-only or in-memory sources).
      - `'offlineFirst'` - runs once, then pauses retries when offline (use when a service worker / HTTP cache may serve the first request).
      
      ## Dependent Queries
      
      Avoid waterfalls by enabling queries in parallel when possible:
      
      ```tsx
      // ❌ Waterfall - queries run sequentially
      function UserDashboard({ userId }) {
        const { data: user } = useQuery({
          queryKey: ['user', userId],
          queryFn: () => fetchUser(userId),
        });
      
        const { data: posts } = useQuery({
          queryKey: ['posts', user?.id],
          queryFn: () => fetchPosts(user.id),
          enabled: !!user?.id, // Waits for user
        });
      
        const { data: comments } = useQuery({
          queryKey: ['comments', user?.id],
          queryFn: () => fetchComments(user.id),
          enabled: !!user?.id, // Also waits for user
        });
      }
      
      // ✅ Optimized - posts and comments fetch in parallel after user loads
      ```
      
      ## Code Splitting
      
      ### Lazy Load Query Client
      
      ```tsx
      import { lazy, Suspense } from 'react';
      import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
      
      const ReactQueryDevtools = lazy(() =>
        import('@tanstack/react-query-devtools').then(mod => ({
          default: mod.ReactQueryDevtools,
        }))
      );
      
      function App() {
        return (
          <QueryClientProvider client={queryClient}>
            <YourApp />
            <Suspense fallback={null}>
              <ReactQueryDevtools />
            </Suspense>
          </QueryClientProvider>
        );
      }
      ```
      
      ## Monitoring Performance
      
      ### Using DevTools
      
      ```tsx
      import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
      
      function App() {
        return (
          <QueryClientProvider client={queryClient}>
            <YourApp />
            <ReactQueryDevtools
              initialIsOpen={false}
              position="bottom-right"
            />
          </QueryClientProvider>
        );
      }
      ```
      
      ### Query/Mutation Cache Callbacks
      
      Use cache-level callbacks for global logging (the `logger` property was removed in v5):
      
      ```tsx
      import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
      
      const queryClient = new QueryClient({
        queryCache: new QueryCache({
          onError: (error, query) => {
            console.error(`[Query Error] ${query.queryKey}:`, error);
          },
          onSuccess: (data, query) => {
            console.log(`[Query Success] ${query.queryKey}`);
          },
        }),
        mutationCache: new MutationCache({
          onError: (error) => {
            console.error('[Mutation Error]:', error);
          },
        }),
      });
      ```
      
      DevTools now shows a visual indicator for queries with `staleTime: Infinity` ("static" queries), making it easier to distinguish intentionally never-refetched queries (v5.80.0+).
      
      ### timeoutManager (Testing)
      
      Replace `setTimeout`/`setInterval` with custom implementations for testing or non-browser environments (v5.87.0+):
      
      ```tsx
      import { timeoutManager } from '@tanstack/query-core';
      
      // In test setup - use fake timers
      timeoutManager.setTimeout = vi.fn(setTimeout);
      timeoutManager.clearTimeout = vi.fn(clearTimeout);
      ```
      
      ### defaultScheduler
      
      The internal notification batching scheduler is now a public export (v5.70.0+), enabling custom scheduling strategies:
      
      ```tsx
      import { defaultScheduler } from '@tanstack/query-core';
      // Use for advanced batching customization
      ```
      
      ### Performance Metrics
      
      ```tsx
      useQuery({
        queryKey: ['todos'],
        queryFn: async () => {
          const start = performance.now();
          const data = await fetchTodos();
          const duration = performance.now() - start;
          console.log(`Query took ${duration}ms`);
          return data;
        },
      });
      ```
      
      ## Best Practices
      
      1. **Set Appropriate staleTime**
         ```tsx
         // Static data
         staleTime: Infinity
      
         // Frequently changing
         staleTime: 0
      
         // Moderate
         staleTime: 1000 * 60 * 5 // 5 minutes
         ```
      
      2. **Use Prefetching**
         - Hover intent
         - Route prediction
         - Next page in pagination
      
      3. **Optimize with select**
         ```tsx
         select: (data) => data.filter(/* ... */)
         ```
      
      4. **Disable Unnecessary Refetching**
         ```tsx
         refetchOnWindowFocus: false
         refetchOnReconnect: false
         ```
      
      5. **Use Structural Sharing**
         - Enabled by default
         - Disable for very large datasets
      
      6. **Implement Virtual Scrolling**
         - For long lists
         - For infinite queries
      
      7. **Monitor with DevTools**
         - Watch for unnecessary refetches
         - Check cache effectiveness
         - Identify slow queries
      
      8. **Batch Parallel Queries**
         - Use useQueries
         - Reduce waterfalls
      
      9. **Clean Up Unused Cache**
         ```tsx
         gcTime: 1000 * 60 * 5
         ```
      
      10. **Use Request Cancellation**
          - Automatically handled by TanStack Query
          - Ensures old requests don't override new ones
      
    • query-typescript.md 19.1 KB
      # TypeScript Guide
      
      TanStack Query v5 is written in TypeScript and provides excellent type safety and inference out of the box.
      
      ## Basic Type Inference
      
      TanStack Query infers types from your query function return values:
      
      ```tsx
      import { useQuery } from '@tanstack/react-query';
      
      interface Todo {
        id: number;
        title: string;
        done: boolean;
      }
      
      function useTodos() {
        return useQuery({
          queryKey: ['todos'],
          queryFn: async (): Promise<Todo[]> => {
            const res = await fetch('/api/todos');
            return res.json();
          },
        });
      }
      
      function TodoList() {
        const { data } = useTodos();
        // data is automatically typed as Todo[] | undefined
      
        return (
          <ul>
            {data?.map((todo) => (
              // todo is typed as Todo
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ul>
        );
      }
      ```
      
      ## Typing Query Functions
      
      ### Inline Query Functions
      
      ```tsx
      const { data } = useQuery({
        queryKey: ['todo', todoId],
        queryFn: async (): Promise<Todo> => {
          const res = await fetch(`/api/todos/${todoId}`);
          if (!res.ok) throw new Error('Failed to fetch');
          return res.json();
        },
      });
      // data is typed as Todo | undefined
      ```
      
      ### Extracted Query Functions
      
      ```tsx
      async function fetchTodo(id: number): Promise<Todo> {
        const res = await fetch(`/api/todos/${id}`);
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      }
      
      const { data } = useQuery({
        queryKey: ['todo', todoId],
        queryFn: () => fetchTodo(todoId),
      });
      // data is automatically typed as Todo | undefined
      ```
      
      ### Query Functions with QueryKey
      
      Access the query key in your function with proper typing:
      
      ```tsx
      import { QueryFunction } from '@tanstack/react-query';
      
      const fetchTodo: QueryFunction<Todo, ['todo', number]> = async ({ queryKey }) => {
        const [_, id] = queryKey;
        // id is typed as number
        const res = await fetch(`/api/todos/${id}`);
        return res.json();
      };
      
      const { data } = useQuery({
        queryKey: ['todo', todoId],
        queryFn: fetchTodo,
      });
      ```
      
      ## Typing Mutations
      
      ### Basic Mutation Types
      
      ```tsx
      import { useMutation } from '@tanstack/react-query';
      
      interface CreateTodoInput {
        title: string;
        done?: boolean;
      }
      
      interface CreateTodoResponse {
        id: number;
        title: string;
        done: boolean;
        createdAt: string;
      }
      
      const mutation = useMutation({
        mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
          const res = await fetch('/api/todos', {
            method: 'POST',
            body: JSON.stringify(input),
          });
          return res.json();
        },
      });
      
      // TypeScript knows:
      // - mutation.mutate expects CreateTodoInput
      // - mutation.data is CreateTodoResponse | undefined
      mutation.mutate({ title: 'New Todo' });
      ```
      
      ### Generic Mutation Type
      
      ```tsx
      import { UseMutationResult } from '@tanstack/react-query';
      
      type CreateTodoMutation = UseMutationResult<
        CreateTodoResponse, // TData - successful response
        Error,              // TError - error type
        CreateTodoInput,    // TVariables - mutation input
        unknown             // TContext - context from onMutate
      >;
      
      function useCreateTodo(): CreateTodoMutation {
        return useMutation({
          mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
            const res = await fetch('/api/todos', {
              method: 'POST',
              body: JSON.stringify(input),
            });
            return res.json();
          },
        });
      }
      ```
      
      ## Error Typing
      
      ### Typed Errors
      
      ```tsx
      interface ApiError {
        message: string;
        code: string;
        details?: Record<string, string>;
      }
      
      const { data, error } = useQuery<Todo[], ApiError>({
        queryKey: ['todos'],
        queryFn: async () => {
          const res = await fetch('/api/todos');
          if (!res.ok) {
            const errorData: ApiError = await res.json();
            throw errorData;
          }
          return res.json();
        },
      });
      
      if (error) {
        // error is typed as ApiError
        console.log(error.message);
        console.log(error.code);
      }
      ```
      
      ### Error Type Narrowing
      
      ```tsx
      function TodoList() {
        const { data, error, isError } = useQuery<Todo[], ApiError>({
          queryKey: ['todos'],
          queryFn: fetchTodos,
        });
      
        if (isError) {
          // TypeScript knows error is ApiError here
          return <div>Error: {error.message} (Code: {error.code})</div>;
        }
      
        // TypeScript knows data is Todo[] | undefined here
        return <div>{data?.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>;
      }
      ```
      
      ## Generic Type Parameters
      
      ### useQuery Generics
      
      ```tsx
      useQuery<
        TData,      // Type of data returned (inferred from queryFn)
        TError,     // Type of errors (default: Error)
        TQueryKey   // Type of query key (inferred)
      >({ /* ... */ });
      ```
      
      Example with all generics:
      
      ```tsx
      interface User {
        id: number;
        name: string;
      }
      
      interface UserError {
        message: string;
        statusCode: number;
      }
      
      const { data, error } = useQuery<User, UserError, ['user', number]>({
        queryKey: ['user', userId],
        queryFn: async ({ queryKey }): Promise<User> => {
          const [_, id] = queryKey;
          const res = await fetch(`/api/users/${id}`);
          if (!res.ok) {
            throw { message: 'Failed to fetch', statusCode: res.status };
          }
          return res.json();
        },
      });
      ```
      
      ### useMutation Generics
      
      ```tsx
      useMutation<
        TData,      // Type of successful response
        TError,     // Type of error
        TVariables, // Type of mutation variables
        TContext    // Type of context from onMutate
      >({ /* ... */ });
      ```
      
      Example:
      
      ```tsx
      interface UpdateTodoInput {
        id: number;
        title?: string;
        done?: boolean;
      }
      
      interface UpdateTodoResponse {
        id: number;
        title: string;
        done: boolean;
        updatedAt: string;
      }
      
      interface UpdateTodoContext {
        previousTodos: Todo[];
      }
      
      const mutation = useMutation<
        UpdateTodoResponse,
        ApiError,
        UpdateTodoInput,
        UpdateTodoContext
      >({
        mutationFn: async (input) => {
          const res = await fetch(`/api/todos/${input.id}`, {
            method: 'PATCH',
            body: JSON.stringify(input),
          });
          return res.json();
        },
        onMutate: async (variables) => {
          const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
          // Must return UpdateTodoContext
          return { previousTodos };
        },
        onError: (error, variables, context) => {
          // error: ApiError
          // variables: UpdateTodoInput
          // context: UpdateTodoContext | undefined
          if (context) {
            queryClient.setQueryData(['todos'], context.previousTodos);
          }
        },
      });
      ```
      
      ## Typing QueryClient Methods
      
      ### setQueryData
      
      ```tsx
      const queryClient = useQueryClient();
      
      // Type-safe setQueryData
      queryClient.setQueryData<Todo[]>(['todos'], (old) => {
        // old is typed as Todo[] | undefined
        return old ? [...old, newTodo] : [newTodo];
      });
      ```
      
      ### getQueryData
      
      ```tsx
      const todos = queryClient.getQueryData<Todo[]>(['todos']);
      // todos is typed as Todo[] | undefined
      
      if (todos) {
        // TypeScript knows todos is Todo[] here
        console.log(todos.length);
      }
      
      // v5.80.0+: When using queryOptions(), getQueryData infers types automatically
      import { queryOptions } from '@tanstack/react-query';
      
      const todosOptions = queryOptions({
        queryKey: ['todos'],
        queryFn: fetchTodos, // returns Todo[]
      });
      
      const cached = queryClient.getQueryData(todosOptions.queryKey);
      // cached is typed as Todo[] | undefined - no manual generic needed
      ```
      
      ### invalidateQueries
      
      ```tsx
      // Type-safe query key
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
      ```
      
      ## Typing Infinite Queries
      
      ### Basic Infinite Query
      
      ```tsx
      interface PostsPage {
        posts: Post[];
        nextCursor: number | null;
      }
      
      const { data } = useInfiniteQuery<PostsPage>({
        queryKey: ['posts'],
        queryFn: async ({ pageParam = 0 }): Promise<PostsPage> => {
          const res = await fetch(`/api/posts?cursor=${pageParam}`);
          return res.json();
        },
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
      });
      
      // data.pages is typed as PostsPage[]
      data?.pages.forEach((page) => {
        // page is typed as PostsPage
        page.posts.forEach((post) => {
          // post is typed as Post
          console.log(post.title);
        });
      });
      ```
      
      ### Infinite Query with Generics
      
      ```tsx
      useInfiniteQuery<
        TData,         // Type of page data
        TError,        // Type of error
        TQueryData,    // Type of transformed data (from select)
        TQueryKey,     // Type of query key
        TPageParam     // Type of page parameter
      >({ /* ... */ });
      ```
      
      Example:
      
      ```tsx
      const { data } = useInfiniteQuery<
        PostsPage,
        ApiError,
        PostsPage,
        ['posts', string],
        number
      >({
        queryKey: ['posts', filter],
        queryFn: async ({ pageParam }): Promise<PostsPage> => {
          const res = await fetch(`/api/posts?cursor=${pageParam}&filter=${filter}`);
          return res.json();
        },
        initialPageParam: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
      });
      ```
      
      ## Typing Select Transformations
      
      ### Transform Query Data
      
      ```tsx
      interface TodoApiResponse {
        id: number;
        title: string;
        completed: boolean;
      }
      
      interface Todo {
        id: number;
        title: string;
        done: boolean; // renamed from completed
      }
      
      const { data } = useQuery({
        queryKey: ['todos'],
        queryFn: async (): Promise<TodoApiResponse[]> => {
          const res = await fetch('/api/todos');
          return res.json();
        },
        select: (data): Todo[] => {
          // data is typed as TodoApiResponse[]
          return data.map(todo => ({
            id: todo.id,
            title: todo.title,
            done: todo.completed, // transform
          }));
        },
      });
      
      // data is now typed as Todo[] | undefined
      ```
      
      ### Partial Selection
      
      ```tsx
      interface User {
        id: number;
        name: string;
        email: string;
        role: string;
        metadata: Record<string, unknown>;
      }
      
      const { data } = useQuery({
        queryKey: ['user', userId],
        queryFn: async (): Promise<User> => {
          const res = await fetch(`/api/users/${userId}`);
          return res.json();
        },
        select: (user) => ({
          id: user.id,
          name: user.name,
        }),
      });
      
      // data is typed as { id: number; name: string } | undefined
      ```
      
      ## isPlaceholderData Type Narrowing
      
      Since v5.65.0, `isPlaceholderData` properly narrows the data type:
      
      ```tsx
      const { data, isPlaceholderData } = useQuery({
        queryKey: ['todos', page],
        queryFn: () => fetchTodos(page),
        placeholderData: (previousData) => previousData,
      });
      
      if (isPlaceholderData) {
        // data is narrowed to the placeholder type
        // TypeScript knows this is stale/previous data
      }
      
      if (!isPlaceholderData && data) {
        // data is narrowed to the fresh query result type
        // Safe to use without additional type guards
      }
      ```
      
      ## Custom Hooks with Types
      
      ### Reusable Typed Hooks
      
      ```tsx
      interface UseTodoOptions {
        refetchInterval?: number;
        enabled?: boolean;
      }
      
      function useTodo(id: number, options?: UseTodoOptions) {
        return useQuery<Todo, ApiError>({
          queryKey: ['todo', id],
          queryFn: async (): Promise<Todo> => {
            const res = await fetch(`/api/todos/${id}`);
            if (!res.ok) {
              const error: ApiError = await res.json();
              throw error;
            }
            return res.json();
          },
          refetchInterval: options?.refetchInterval,
          enabled: options?.enabled,
        });
      }
      
      // Usage with full type safety
      const { data, error, isLoading } = useTodo(1, { refetchInterval: 5000 });
      ```
      
      ### Generic Custom Hooks
      
      ```tsx
      function useResource<T>(resourceType: string, id: number) {
        return useQuery<T, ApiError>({
          queryKey: [resourceType, id],
          queryFn: async (): Promise<T> => {
            const res = await fetch(`/api/${resourceType}/${id}`);
            if (!res.ok) throw await res.json();
            return res.json();
          },
        });
      }
      
      // Usage
      const { data: user } = useResource<User>('users', 1);
      // data is typed as User | undefined
      
      const { data: post } = useResource<Post>('posts', 123);
      // data is typed as Post | undefined
      ```
      
      ## Typing Query Keys
      
      ### Const Query Keys
      
      ```tsx
      const todoKeys = {
        all: ['todos'] as const,
        lists: () => [...todoKeys.all, 'list'] as const,
        list: (filters: string) => [...todoKeys.lists(), filters] as const,
        details: () => [...todoKeys.all, 'detail'] as const,
        detail: (id: number) => [...todoKeys.details(), id] as const,
      };
      
      // Type-safe query keys
      useQuery({
        queryKey: todoKeys.detail(todoId),
        queryFn: () => fetchTodo(todoId),
      });
      
      // Type-safe invalidation
      queryClient.invalidateQueries({ queryKey: todoKeys.all });
      queryClient.invalidateQueries({ queryKey: todoKeys.detail(todoId) });
      ```
      
      **QueryFilters with partial keys and readonly (v5.90.8+):**
      
      `QueryFilters` now supports partial query keys and preserves `readonly` from `as const` assertions, so you can filter by key prefix without losing type safety:
      
      ```tsx
      // as const works seamlessly with QueryFilters
      queryClient.invalidateQueries({ queryKey: todoKeys.all }); // readonly ['todos']
      ```
      
      ### QueryKey Type Helper
      
      ```tsx
      import { QueryKey } from '@tanstack/react-query';
      
      type TodoQueryKey = ['todos'] | ['todos', 'list', string] | ['todos', 'detail', number];
      
      function useTodoQuery(key: TodoQueryKey) {
        return useQuery({
          queryKey: key,
          queryFn: async () => {
            // Implementation based on key
          },
        });
      }
      ```
      
      ## Typing Mutation Context
      
      ### Context with Optimistic Updates
      
      ```tsx
      interface UpdateTodoVariables {
        id: number;
        updates: Partial<Todo>;
      }
      
      interface UpdateTodoContext {
        previousTodos: Todo[];
        previousTodo: Todo;
        rollback: () => void;
      }
      
      const mutation = useMutation<
        Todo,
        ApiError,
        UpdateTodoVariables,
        UpdateTodoContext
      >({
        mutationFn: async ({ id, updates }) => {
          const res = await fetch(`/api/todos/${id}`, {
            method: 'PATCH',
            body: JSON.stringify(updates),
          });
          return res.json();
        },
        onMutate: async ({ id, updates }): Promise<UpdateTodoContext> => {
          await queryClient.cancelQueries({ queryKey: ['todos'] });
      
          const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
          const previousTodo = queryClient.getQueryData<Todo>(['todo', id])!;
      
          // Optimistic update
          queryClient.setQueryData<Todo[]>(['todos'], (old) =>
            old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
          );
      
          const rollback = () => {
            queryClient.setQueryData(['todos'], previousTodos);
            queryClient.setQueryData(['todo', id], previousTodo);
          };
      
          return { previousTodos, previousTodo, rollback };
        },
        onError: (_error, _variables, context) => {
          // context is typed as UpdateTodoContext | undefined
          context?.rollback();
        },
      });
      ```
      
      ## Type-Safe Query Options
      
      ### queryOptions() Helper (Recommended)
      
      The `queryOptions()` helper creates reusable, fully type-safe query configurations:
      
      ```tsx
      import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';
      
      function todoQueryOptions(id: number) {
        return queryOptions({
          queryKey: ['todo', id] as const,
          queryFn: () => fetchTodo(id),
          staleTime: 1000 * 60 * 5,
        });
      }
      
      // Full type inference - no manual generics needed
      const { data } = useQuery(todoQueryOptions(todoId));
      // data is typed as Todo | undefined
      
      // Works with prefetching
      queryClient.prefetchQuery(todoQueryOptions(todoId));
      
      // Works with getQueryData
      const cached = queryClient.getQueryData(todoQueryOptions(todoId).queryKey);
      // cached is typed as Todo | undefined
      
      // Works with invalidation
      queryClient.invalidateQueries({ queryKey: todoQueryOptions(todoId).queryKey });
      ```
      
      ### Manual Query Options (Alternative)
      
      ```tsx
      import { UseQueryOptions } from '@tanstack/react-query';
      
      type TodoQueryOptions = UseQueryOptions<Todo, ApiError, Todo, ['todo', number]>;
      
      const defaultTodoOptions: Partial<TodoQueryOptions> = {
        staleTime: 1000 * 60 * 5,
        retry: 3,
      };
      
      function useTodo(id: number, options?: Partial<TodoQueryOptions>) {
        return useQuery<Todo, ApiError>({
          queryKey: ['todo', id],
          queryFn: () => fetchTodo(id),
          ...defaultTodoOptions,
          ...options,
        });
      }
      ```
      
      ## Strict Type Safety
      
      ### Enable Strict Mode
      
      ```tsx
      // tsconfig.json
      {
        "compilerOptions": {
          "strict": true,
          "strictNullChecks": true,
          "noImplicitAny": true
        }
      }
      ```
      
      ### Avoid Type Assertions
      
      ```tsx
      // ❌ Bad - using type assertion
      const data = queryClient.getQueryData(['todos']) as Todo[];
      
      // ✅ Good - proper type checking
      const data = queryClient.getQueryData<Todo[]>(['todos']);
      if (data) {
        // TypeScript knows data is Todo[] here
        console.log(data.length);
      }
      ```
      
      ### Non-Null Assertions
      
      Use sparingly and only when you're certain:
      
      ```tsx
      // ❌ Risky
      const todos = queryClient.getQueryData<Todo[]>(['todos'])!;
      
      // ✅ Better
      const todos = queryClient.getQueryData<Todo[]>(['todos']);
      if (!todos) {
        throw new Error('Todos not found in cache');
      }
      // Now safe to use todos
      ```
      
      ## Typing DevTools
      
      ```tsx
      import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
      
      function App() {
        return (
          <QueryClientProvider client={queryClient}>
            <YourApp />
            {/* TypeScript will check props */}
            <ReactQueryDevtools
              initialIsOpen={false}
              buttonPosition="bottom-right"
            />
          </QueryClientProvider>
        );
      }
      ```
      
      ## Common Type Issues
      
      ### Issue: Cannot infer type from async function
      
      ```tsx
      // ❌ Problem
      const { data } = useQuery({
        queryKey: ['todos'],
        queryFn: async () => {
          const res = await fetch('/api/todos');
          return res.json(); // Returns any
        },
      });
      
      // ✅ Solution 1: Add return type to queryFn
      const { data } = useQuery({
        queryKey: ['todos'],
        queryFn: async (): Promise<Todo[]> => {
          const res = await fetch('/api/todos');
          return res.json();
        },
      });
      
      // ✅ Solution 2: Use generic parameter
      const { data } = useQuery<Todo[]>({
        queryKey: ['todos'],
        queryFn: async () => {
          const res = await fetch('/api/todos');
          return res.json();
        },
      });
      ```
      
      ### Issue: Context type mismatch
      
      ```tsx
      // ❌ Problem - context type doesn't match
      const mutation = useMutation({
        mutationFn: updateTodo,
        onMutate: () => {
          return { previous: [] }; // Returns wrong type
        },
        onError: (err, vars, context) => {
          context.previousTodos; // Error: previousTodos doesn't exist
        },
      });
      
      // ✅ Solution - Define context interface
      interface MutationContext {
        previousTodos: Todo[];
      }
      
      const mutation = useMutation<Todo, Error, UpdateInput, MutationContext>({
        mutationFn: updateTodo,
        onMutate: async (): Promise<MutationContext> => {
          const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
          return { previousTodos };
        },
        onError: (err, vars, context) => {
          if (context) {
            context.previousTodos; // ✅ Properly typed
          }
        },
      });
      ```
      
      ## Best Practices
      
      1. **Always type your query functions**
         ```tsx
         queryFn: async (): Promise<Todo[]> => { /* ... */ }
         ```
      
      2. **Use type inference when possible**
         ```tsx
         // Let TanStack Query infer types from queryFn return type
         const { data } = useQuery({
           queryKey: ['todos'],
           queryFn: async (): Promise<Todo[]> => fetchTodos(),
         });
         // data is automatically Todo[] | undefined
         ```
      
      3. **Define error types**
         ```tsx
         const { error } = useQuery<Todo[], ApiError>({ /* ... */ });
         ```
      
      4. **Use const assertions for query keys**
         ```tsx
         const todoKeys = {
           all: ['todos'] as const,
           detail: (id: number) => ['todos', id] as const,
         };
         ```
      
      5. **Create reusable typed hooks**
         ```tsx
         function useTodo(id: number) {
           return useQuery<Todo, ApiError>({ /* ... */ });
         }
         ```
      
      6. **Type mutation context for optimistic updates**
         ```tsx
         useMutation<TData, TError, TVariables, TContext>({ /* ... */ })
         ```
      
      7. **Use strict TypeScript settings**
         ```json
         {
           "strict": true,
           "strictNullChecks": true
         }
         ```
      
      8. **Avoid type assertions**
         - Use type parameters instead
         - Check for undefined/null before using data
      
    • router-guide.md 14.8 KB
      
      # 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 `start-guide.md`)
      
      ## Quick Start Workflow
      
      ### 1. Install and Configure (Vite)
      
      ```bash
      npm install @tanstack/react-router @tanstack/router-plugin
      ```
      
      ```ts
      // 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
      
      ```tsx
      // 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 />
          </>
        ),
      })
      ```
      
      ```tsx
      // 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
      
      ```tsx
      // 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
        }
      }
      ```
      
      ```tsx
      // 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
      
      ```tsx
      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:
      
      ```tsx
      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:
      
      ```tsx
      import { linkOptions } from '@tanstack/react-router'
      
      const postLink = linkOptions({ to: '/posts/$postId', params: { postId: '123' } })
      <Link {...postLink}>View Post</Link>
      ```
      
      **`to` rejects arbitrary strings.** Because routes are typed, `Link`/`useNavigate` `to` will not accept a plain `string` variable - passing a dynamic URL is a type error. For a known internal route use `linkOptions()`; for genuinely dynamic or external URLs, render a plain `<a href={url}>` (the external-link escape hatch) rather than casting.
      
      Always provide `from` on Link and hooks to narrow types and improve TS performance. Without `from`, TypeScript must check against all routes.
      
      ### Custom Link Components (`createLink` / `useLinkProps`)
      
      To give a third-party or styled component (a UI-library `<Button>`, a framer-motion anchor) the same type-safe `to`/`params`/`search`/`activeProps` API as `Link`, wrap it with `createLink`. For full manual control, `useLinkProps` returns the resolved anchor props (href, active state, click handler) to spread onto your own element.
      
      ```tsx
      import { createLink } from '@tanstack/react-router'
      
      // Component must forward a ref and accept anchor props
      const BasicLink = React.forwardRef<HTMLAnchorElement, React.AnchorHTMLAttributes<HTMLAnchorElement>>(
        (props, ref) => <a ref={ref} {...props} className="my-link" />,
      )
      
      export const AppLink = createLink(BasicLink) // now typed: <AppLink to="/posts/$postId" params={{ postId: '1' }} />
      ```
      
      If the wrapped component defines its own props, intersect them so both sets stay typed. Prefer `createLink` over hand-rolling an `<a>` when you need active styling or preloading.
      
      ## Search Params
      
      Search params are first-class - validated, typed, JSON-serialized, and subscribable with fine-grained selectors.
      
      ### Validation with Zod
      
      ```tsx
      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
      
      ```tsx
      // 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
      
      ```tsx
      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
      
      ```tsx
      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
      
      ```tsx
      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:
      
      ```tsx
      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
      
      ```tsx
      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.
      
      ```tsx
      // 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`:
      
      ```tsx
      function App() {
        const auth = useAuth()
        return <RouterProvider router={router} context={{ auth }} />
      }
      ```
      
      ## Error Handling
      
      ### errorComponent
      
      ```tsx
      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
      
      ```tsx
      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
      
      ```tsx
      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
      
      ```tsx
      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:
      - `search-params.md` - Custom serialization, Standard Schema validation, arrays/objects, sharing across routes
      - `data-loading.md` - Deferred data loading with Await, external data loading, shouldReload, streaming SSR
      - `routing-patterns.md` - Virtual file routes, route masking, navigation blocking, authenticated routes, parallel routes
      - `code-splitting.md` - Automatic splitting options, loader splitting, directory encapsulation, code-based splitting
      - `router-ssr.md` - SSR setup, streaming, dehydration/hydration, data serialization, TanStack Start integration
      
      ## DevTools
      
      ```bash
      npm install @tanstack/react-router-devtools
      ```
      
      ```tsx
      // 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
      
      - **Official Docs**: https://tanstack.com/router/latest/docs/framework/react/overview
      - **GitHub**: https://github.com/TanStack/router
      - **Examples**: https://tanstack.com/router/latest/docs/framework/react/examples
      - **Query Integration**: https://tanstack.com/router/latest/docs/router/integrations/query
      - **Discord**: https://discord.gg/tanstack
      - **Migrate from React Router**: https://tanstack.com/router/latest/docs/framework/react/installation/migrate-from-react-router
      
    • router-ssr.md 17.8 KB
      # Server-Side Rendering (SSR)
      
      TanStack Router provides low-level SSR primitives for rendering your application on the server and hydrating it on the client. It supports both non-streaming and streaming SSR, automatic data serialization, and document head management.
      
      > **For most SSR use cases, [TanStack Start](https://tanstack.com/start/latest) is the recommended approach.** Start provides SSR, streaming, server functions, and deployment with zero configuration. The APIs documented here are lower-level building blocks intended for custom server setups or integration with existing server frameworks. These APIs share internal implementations with TanStack Start and should be considered experimental until Start reaches stable status.
      
      Official documentation: https://tanstack.com/router/latest/docs/framework/react/guide/ssr
      
      ## SSR Overview
      
      Two flavors of SSR are supported:
      
      - **Non-Streaming SSR** - The entire page is rendered on the server and sent as a single HTML response, including serialized data needed for client hydration.
      - **Streaming SSR** - The critical first paint is sent immediately, and remaining content is progressively streamed to the client as it becomes available.
      
      Key SSR utilities are split across two import paths:
      
      - `@tanstack/react-router/ssr/server` - `createRequestHandler`, `defaultRenderHandler`, `renderRouterToString`, `defaultStreamHandler`, `renderRouterToStream`, `RouterServer`
      - `@tanstack/react-router/ssr/client` - `RouterClient`
      
      ## Shared Router Configuration
      
      Since your router exists on both the server and the client, create it in a shared file:
      
      ```tsx
      // src/router.tsx
      import { createRouter as createTanstackRouter } from '@tanstack/react-router'
      import { routeTree } from './routeTree.gen'
      
      export function createRouter() {
        return createTanstackRouter({
          routeTree,
          context: { head: '' },
          defaultPreload: 'intent',
          scrollRestoration: true,
        })
      }
      
      declare module '@tanstack/react-router' {
        interface Register {
          router: ReturnType<typeof createRouter>
        }
      }
      ```
      
      ## Non-Streaming SSR
      
      ### Server Entry with defaultRenderHandler
      
      The simplest approach - handles wrapping and hydration automatically:
      
      ```tsx
      // src/entry-server.tsx
      import {
        createRequestHandler,
        defaultRenderHandler,
      } from '@tanstack/react-router/ssr/server'
      import { createRouter } from './router'
      
      export async function render({ request }: { request: Request }) {
        const handler = createRequestHandler({ request, createRouter })
        return await handler(defaultRenderHandler)
      }
      ```
      
      ### Server Entry with renderRouterToString
      
      For more control, use `renderRouterToString` with `RouterServer` to manually specify wrapping providers:
      
      ```tsx
      // src/entry-server.tsx
      import {
        createRequestHandler,
        renderRouterToString,
        RouterServer,
      } from '@tanstack/react-router/ssr/server'
      import { createRouter } from './router'
      
      export function render({ request }: { request: Request }) {
        const handler = createRequestHandler({ request, createRouter })
      
        return handler(({ responseHeaders, router }) =>
          renderRouterToString({
            responseHeaders,
            router,
            children: <RouterServer router={router} />,
          }),
        )
      }
      ```
      
      ### Client Entry
      
      The client entry is the same for both streaming and non-streaming SSR:
      
      ```tsx
      // src/entry-client.tsx
      import { hydrateRoot } from 'react-dom/client'
      import { RouterClient } from '@tanstack/react-router/ssr/client'
      import { createRouter } from './router'
      
      const router = createRouter()
      hydrateRoot(document, <RouterClient router={router} />)
      ```
      
      ## Streaming SSR
      
      Streaming SSR sends the critical first paint immediately, then progressively streams remaining content. Useful for pages with slow or high-latency data fetching.
      
      ### Server Entry with defaultStreamHandler
      
      ```tsx
      // src/entry-server.tsx
      import {
        createRequestHandler,
        defaultStreamHandler,
      } from '@tanstack/react-router/ssr/server'
      import { createRouter } from './router'
      
      export async function render({ request }: { request: Request }) {
        const handler = createRequestHandler({ request, createRouter })
        return await handler(defaultStreamHandler)
      }
      ```
      
      ### Server Entry with renderRouterToStream
      
      ```tsx
      // src/entry-server.tsx
      import {
        createRequestHandler,
        renderRouterToStream,
        RouterServer,
      } from '@tanstack/react-router/ssr/server'
      import { createRouter } from './router'
      
      export function render({ request }: { request: Request }) {
        const handler = createRequestHandler({ request, createRouter })
      
        return handler(({ request, responseHeaders, router }) =>
          renderRouterToStream({
            request,
            responseHeaders,
            router,
            children: <RouterServer router={router} />,
          }),
        )
      }
      ```
      
      ### Vite Configuration for Streaming
      
      Enable streaming support in the router plugin:
      
      ```ts
      // vite.config.ts
      import { tanstackRouter } from '@tanstack/router-plugin/vite'
      import { defineConfig } from 'vite'
      import react from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [
          tanstackRouter({ autoCodeSplitting: true, enableStreaming: true }),
          react(),
        ],
        ssr: {
          optimizeDeps: {
            include: ['@tanstack/react-router/ssr/server'],
          },
        },
      })
      ```
      
      ## Express Integration
      
      `createRequestHandler` requires a Web API standard `Request` and returns a `Response` promise. When using Express, convert between the two:
      
      ```tsx
      // src/entry-server.tsx
      import { pipeline } from 'node:stream/promises'
      import {
        RouterServer,
        createRequestHandler,
        renderRouterToStream,
      } from '@tanstack/react-router/ssr/server'
      import { createRouter } from './router'
      import type express from 'express'
      
      export async function render({
        req,
        res,
        head = '',
      }: {
        head?: string
        req: express.Request
        res: express.Response
      }) {
        const url = new URL(req.originalUrl || req.url, 'https://localhost:3000').href
      
        const request = new Request(url, {
          method: req.method,
          headers: (() => {
            const headers = new Headers()
            for (const [key, value] of Object.entries(req.headers)) {
              headers.set(key, value as any)
            }
            return headers
          })(),
        })
      
        const handler = createRequestHandler({
          request,
          createRouter: () => {
            const router = createRouter()
            router.update({
              context: { ...router.options.context, head },
            })
            return router
          },
        })
      
        const response = await handler(({ request, responseHeaders, router }) =>
          renderRouterToStream({
            request,
            responseHeaders,
            router,
            children: <RouterServer router={router} />,
          }),
        )
      
        res.statusMessage = response.statusText
        res.status(response.status)
        response.headers.forEach((value, name) => res.setHeader(name, value))
        return pipeline(response.body as any, res)
      }
      ```
      
      ## Data Serialization
      
      Resolved loader data is automatically dehydrated on the server and rehydrated on the client. TanStack Router uses a lightweight serializer that supports types beyond `JSON.stringify`/`JSON.parse`:
      
      | Type | Supported |
      |------|-----------|
      | `string`, `number`, `boolean`, `null` | Yes (standard JSON) |
      | `Date` | Yes |
      | `Error` | Yes |
      | `FormData` | Yes |
      | `undefined` | Yes |
      
      For complex types like `Map`, `Set`, or `BigInt`, a custom serializer is needed. Custom serializer support is under active development.
      
      When using deferred data streaming, the streaming SSR pattern (`defaultStreamHandler` or `renderRouterToStream`) is required for proper dehydration/hydration of streamed data.
      
      ## History Types
      
      TanStack Router uses `@tanstack/history` to manage routing history. Three types are available:
      
      ```tsx
      import {
        createBrowserHistory,
        createHashHistory,
        createMemoryHistory,
        createRouter,
      } from '@tanstack/react-router'
      
      // Browser history (default on client) - uses browser History API
      const browserHistory = createBrowserHistory()
      
      // Hash history - uses URL hash fragment, useful without server URL rewrites
      const hashHistory = createHashHistory()
      
      // Memory history - required on the server where window does not exist
      const memoryHistory = createMemoryHistory({ initialEntries: ['/'] })
      
      const router = createRouter({ routeTree, history: memoryHistory })
      ```
      
      When using `RouterServer`, memory history is configured automatically. You do not need to manually create a memory history instance for SSR. On the client, `RouterClient` defaults to browser history.
      
      ## Document Head Management
      
      TanStack Router provides built-in document head management for `<title>`, `<meta>`, `<link>`, `<style>`, and `<script>` tags.
      
      Official documentation: https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management
      
      ### HeadContent and Scripts Components
      
      `<HeadContent />` renders head-related tags. `<Scripts />` renders body scripts before the main entry point. Both are required for SSR:
      
      ```tsx
      import {
        HeadContent,
        Outlet,
        Scripts,
        createRootRoute,
      } from '@tanstack/react-router'
      
      export const Route = createRootRoute({
        component: () => (
          <html lang="en">
            <head>
              <HeadContent />
            </head>
            <body>
              <Outlet />
              <Scripts />
            </body>
          </html>
        ),
      })
      ```
      
      For SPAs without server-rendered HTML structure, render `<HeadContent />` as high as possible in the component tree (no `<head>` wrapper needed).
      
      ### ScriptOnce
      
      Renders an inline script that executes before React hydration and removes itself from the DOM. On client-side navigation, nothing renders (prevents duplicate execution):
      
      ```tsx
      import { ScriptOnce } from '@tanstack/react-router'
      
      const themeScript = `(function() {
        try {
          const theme = localStorage.getItem('theme') || 'auto';
          const resolved = theme === 'auto'
            ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
            : theme;
          document.documentElement.classList.add(resolved);
        } catch (e) {}
      })();`
      
      function ThemeProvider({ children }: { children: React.ReactNode }) {
        return (
          <>
            <ScriptOnce children={themeScript} />
            {children}
          </>
        )
      }
      ```
      
      When `ScriptOnce` modifies the DOM before hydration, add `suppressHydrationWarning` to the affected element (e.g. `<html lang="en" suppressHydrationWarning>`).
      
      ### Route head() Property
      
      Define head metadata for any route. Returns an object with `meta`, `links`, `styles`, and `scripts` arrays:
      
      ```tsx
      export const Route = createRootRouteWithContext<RouterContext>()({
        head: () => ({
          meta: [
            { charSet: 'UTF-8' },
            { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
            { name: 'description', content: 'My application description' },
            { title: 'My App' },
          ],
          links: [
            { rel: 'icon', href: '/favicon.ico' },
          ],
          styles: [
            {
              media: 'all and (max-width: 500px)',
              children: `p { color: blue; }`,
            },
          ],
          scripts: [
            { src: 'https://www.google-analytics.com/analytics.js' },
          ],
        }),
      })
      ```
      
      Body scripts use the top-level `scripts` route option (rendered via `<Scripts />`):
      
      ```tsx
      export const Route = createRootRoute({
        scripts: () => [{ children: 'console.log("Hello from body script")' }],
      })
      ```
      
      ### Meta Tag Deduping
      
      TanStack Router automatically dedupes `title` and `meta` tags across nested routes:
      
      - `title` tags in child routes override parent route titles
      - `meta` tags with the same `name` or `property` are overridden by the last occurrence in nested routes
      
      ### Dynamic Head with loaderData
      
      The `head()` function receives `loaderData` from the route's loader:
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params: { postId } }) => {
          const post = await fetchPost(postId)
          return { title: post.title, description: post.excerpt }
        },
        head: ({ loaderData }) => ({
          meta: loaderData
            ? [
                { title: loaderData.title },
                { name: 'description', content: loaderData.description },
              ]
            : undefined,
        }),
        component: PostComponent,
      })
      ```
      
      ### Composable Nested Route Heads
      
      Each route in a hierarchy can define its own `head()`. Tags merge from parent to child, with child routes overriding duplicates:
      
      ```tsx
      // routes/__root.tsx - base defaults
      export const Route = createRootRoute({
        head: () => ({
          meta: [
            { charSet: 'UTF-8' },
            { title: 'My App' },
            { name: 'description', content: 'Default description' },
          ],
        }),
      })
      
      // routes/blog/$slug.tsx - overrides title and description with loader data
      export const Route = createFileRoute('/blog/$slug')({
        loader: async ({ params }) => fetchPost(params.slug),
        head: ({ loaderData }) => ({
          meta: loaderData
            ? [
                { title: `${loaderData.title} - My App` },
                { name: 'description', content: loaderData.excerpt },
              ]
            : undefined,
        }),
      })
      ```
      
      ## SEO Patterns
      
      ### Open Graph and Twitter Cards
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => fetchPost(params.postId),
        head: ({ loaderData }) => ({
          meta: loaderData
            ? [
                { title: loaderData.title },
                { name: 'description', content: loaderData.excerpt },
                { property: 'og:title', content: loaderData.title },
                { property: 'og:description', content: loaderData.excerpt },
                { property: 'og:image', content: loaderData.coverImage },
                { property: 'og:type', content: 'article' },
                { property: 'og:url', content: `https://example.com/posts/${loaderData.slug}` },
                { name: 'twitter:card', content: 'summary_large_image' },
                { name: 'twitter:title', content: loaderData.title },
                { name: 'twitter:description', content: loaderData.excerpt },
                { name: 'twitter:image', content: loaderData.coverImage },
              ]
            : undefined,
          links: loaderData
            ? [{ rel: 'canonical', href: `https://example.com/posts/${loaderData.slug}` }]
            : undefined,
        }),
      })
      ```
      
      ### Structured Data (JSON-LD)
      
      Use the `scripts` array in `head()` to inject JSON-LD:
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => fetchPost(params.postId),
        head: ({ loaderData }) => ({
          meta: loaderData ? [{ title: loaderData.title }] : undefined,
          scripts: loaderData
            ? [
                {
                  type: 'application/ld+json',
                  children: JSON.stringify({
                    '@context': 'https://schema.org',
                    '@type': 'Article',
                    headline: loaderData.title,
                    description: loaderData.excerpt,
                    image: loaderData.coverImage,
                    author: { '@type': 'Person', name: loaderData.author.name },
                    datePublished: loaderData.publishedAt,
                    dateModified: loaderData.updatedAt,
                  }),
                },
              ]
            : undefined,
        }),
      })
      ```
      
      ### Reusable SEO Helper
      
      Create a shared utility for consistent meta tags across routes:
      
      ```tsx
      // src/utils/seo.ts
      interface SeoOptions {
        title: string
        description: string
        image?: string
        url?: string
        type?: string
        twitterCard?: 'summary' | 'summary_large_image'
        twitterSite?: string
      }
      
      export function seo(options: SeoOptions) {
        const {
          title, description, image, url,
          type = 'website',
          twitterCard = 'summary_large_image',
          twitterSite,
        } = options
      
        return {
          meta: [
            { title },
            { name: 'description', content: description },
            { property: 'og:title', content: title },
            { property: 'og:description', content: description },
            { property: 'og:type', content: type },
            ...(image ? [{ property: 'og:image', content: image }] : []),
            ...(url ? [{ property: 'og:url', content: url }] : []),
            { name: 'twitter:card', content: twitterCard },
            { name: 'twitter:title', content: title },
            { name: 'twitter:description', content: description },
            ...(image ? [{ name: 'twitter:image', content: image }] : []),
            ...(twitterSite ? [{ name: 'twitter:site', content: twitterSite }] : []),
          ],
          links: url ? [{ rel: 'canonical', href: url }] : [],
        }
      }
      
      // Usage in a route:
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => fetchPost(params.postId),
        head: ({ loaderData }) => {
          if (!loaderData) return {}
          return seo({
            title: `${loaderData.title} - My Blog`,
            description: loaderData.excerpt,
            image: loaderData.coverImage,
            url: `https://example.com/posts/${loaderData.slug}`,
            type: 'article',
          })
        },
      })
      ```
      
      ## Best Practices
      
      1. **Use TanStack Start for new projects.** Start handles SSR, streaming, server functions, code splitting, and deployment out of the box. Only use manual Router SSR when integrating with an existing custom server or when you need fine-grained control over the rendering pipeline.
      
      2. **Always create the router in a shared file.** Both server and client entry points must import the same `createRouter` function to ensure consistent route trees and configuration between environments.
      
      3. **Prefer streaming SSR for pages with slow data.** Use `renderRouterToStream` or `defaultStreamHandler` when routes fetch from slow APIs. The critical first paint arrives immediately while deferred data streams in progressively.
      
      4. **Place HeadContent in the head and Scripts at the end of body.** `<HeadContent />` must be rendered inside `<head>` (or as high as possible in SPAs). `<Scripts />` goes inside `<body>`, after `<Outlet />`, so body scripts load before hydration but after the DOM is available.
      
      5. **Use the seo helper pattern for consistent meta tags.** A shared utility that generates Open Graph, Twitter Card, and canonical URL tags from a single options object prevents tag omissions and keeps structure consistent.
      
      6. **Set `suppressHydrationWarning` on elements modified by ScriptOnce.** If an inline script modifies the DOM before React hydrates (like adding a theme class to `<html>`), add `suppressHydrationWarning` to the affected element.
      
      7. **Handle Express-to-Web-API conversion carefully.** `createRequestHandler` expects a standard `Request` and returns a `Response`. When using Express, convert `req` to `Request` and pipe `response.body` back through `res` using `pipeline` from `node:stream/promises`.
      
    • routing-patterns.md 21.1 KB
      # Routing Patterns
      
      TanStack Router provides a flexible routing system with multiple approaches: file-based routing (recommended), code-based routing, and virtual file routes. This reference covers all routing patterns, naming conventions, and advanced features.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing
      
      ## File-Based Routing
      
      File-based routing uses the filesystem to define route hierarchy. The TanStack Router bundler plugin automatically generates `routeTree.gen.ts` from your route directory.
      
      ### Vite Plugin Setup
      
      ```ts
      // 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({
            target: 'react',
            // routesDirectory defaults to './src/routes'
            // generatedRouteTree defaults to './src/routeTree.gen.ts'
          }),
          react(),
        ],
      })
      ```
      
      The plugin watches `src/routes/` during development and regenerates `routeTree.gen.ts` on route file changes.
      
      ### Route File Anatomy
      
      Every route file uses `createFileRoute` (path auto-managed by the plugin):
      
      ```tsx
      // src/routes/about.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/about')({
        component: AboutComponent,
      })
      
      function AboutComponent() {
        return <div>About</div>
      }
      ```
      
      The root route uses `createRootRoute` and must be named `__root.tsx`:
      
      ```tsx
      // src/routes/__root.tsx
      import { createRootRoute, Outlet } from '@tanstack/react-router'
      
      export const Route = createRootRoute({
        component: () => (
          <div>
            <nav>{/* navigation */}</nav>
            <Outlet />
          </div>
        ),
      })
      ```
      
      ### Directory Routes
      
      Directories represent route hierarchy:
      
      ```
      src/routes/
        __root.tsx                    ->  always rendered
        index.tsx                     ->  / (exact)
        about.tsx                     ->  /about
        posts.tsx                     ->  /posts (layout)
        posts/
          index.tsx                   ->  /posts (exact)
          $postId.tsx                 ->  /posts/$postId
        settings.tsx                  ->  /settings (layout)
        settings/
          profile.tsx                 ->  /settings/profile
          notifications.tsx           ->  /settings/notifications
      ```
      
      ### Flat Routes (Dot Notation)
      
      Use `.` in filenames to denote nesting without directories:
      
      ```
      src/routes/
        __root.tsx
        index.tsx                        ->  /
        posts.tsx                        ->  /posts
        posts.index.tsx                  ->  /posts (exact)
        posts.$postId.tsx                ->  /posts/$postId
        settings.tsx                     ->  /settings
        settings.profile.tsx             ->  /settings/profile
        settings.notifications.tsx       ->  /settings/notifications
      ```
      
      Both styles can be freely combined in a single project.
      
      ## File Naming Conventions
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/file-naming-conventions
      
      ### `$param` - Dynamic Segments
      
      The `$` token followed by a label creates a dynamic path parameter:
      
      ```tsx
      // src/routes/posts.$postId.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/posts/$postId')({
        loader: ({ params }) => fetchPost(params.postId),
        component: PostComponent,
      })
      
      function PostComponent() {
        const { postId } = Route.useParams()
        return <div>Post ID: {postId}</div>
      }
      ```
      
      Multiple dynamic segments work at each level: `/posts/$postId/$revisionId` captures both.
      
      ### `$` - Splat / Catch-All
      
      A path of only `$` captures all remaining segments into `params._splat`:
      
      ```tsx
      // src/routes/files/$.tsx  ->  /files/*
      export const Route = createFileRoute('/files/$')({
        component: () => {
          const { _splat } = Route.useParams()
          // URL /files/documents/report.pdf -> _splat = "documents/report.pdf"
          return <div>File path: {_splat}</div>
        },
      })
      ```
      
      ### `_` Prefix - Pathless Layout Routes
      
      Wraps children without consuming a URL segment. The part after `_` is the route's unique ID.
      
      ```
      # Flat                              # Directory
      _auth.tsx           (layout only)   _auth/
      _auth.login.tsx     /login            route.tsx   (layout only)
      _auth.register.tsx  /register         login.tsx   /login
                                            register.tsx /register
      ```
      
      ```tsx
      // src/routes/_auth.tsx
      import { Outlet, createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/_auth')({
        component: () => (
          <div className="auth-layout">
            <h1>Authentication</h1>
            <Outlet />
          </div>
        ),
      })
      ```
      
      | URL | Rendered Components |
      |-----|-------------------|
      | `/login` | `<Root><AuthLayout><Login>` |
      | `/register` | `<Root><AuthLayout><Register>` |
      
      Pathless layout routes cannot use dynamic segments in their ID (no `_$postId/`).
      
      ### `_` Suffix - Non-Nested Routes
      
      Breaks a route out of its parent's component nesting while keeping the URL path:
      
      ```
      src/routes/
        posts.tsx                      ->  /posts (layout)
        posts.$postId.tsx              ->  /posts/$postId (nested under posts)
        posts_.$postId.edit.tsx        ->  /posts/$postId/edit (NOT nested)
      ```
      
      | URL | Rendered Components |
      |-----|-------------------|
      | `/posts/123` | `<Root><Posts><Post>` |
      | `/posts/123/edit` | `<Root><PostEditor>` (outside Posts layout) |
      
      ### `-` Prefix - Excluded Files
      
      Files and directories prefixed with `-` are excluded from route generation. Use for colocation:
      
      ```
      src/routes/
        posts.tsx
        -posts-table.tsx               ->  ignored by router
        -components/                   ->  ignored by router
          header.tsx
      ```
      
      Import normally: `import { PostsTable } from './-posts-table'`
      
      **Also silently excluded: dot-prefixed files and directories.** Anything starting with `.` (e.g. `.well-known/`) is ignored by route generation and never appears in `routeTree.gen.ts` - with no error. This bites server routes for OAuth/discovery endpoints like `/.well-known/oauth-authorization-server`. Serve those another way (a non-dotted path, a server route file named via escaped matching, or framework-level static handling).
      
      ### `()` - Route Group Directories
      
      Purely organizational directories that do not affect URL or component tree:
      
      ```
      src/routes/
        (app)/
          dashboard.tsx                ->  /dashboard
          settings.tsx                 ->  /settings
        (auth)/
          login.tsx                    ->  /login
      ```
      
      ### Other Conventions
      
      - **`route.tsx`** - In directories, provides the route config at that path level (e.g., `account/route.tsx` for `/account`)
      - **`index` token** - Matches parent route exactly when no child segments follow
      - **`[x]` escaping** - Square brackets escape special chars: `script[.]js.tsx` becomes `/script.js`
      - **`{-$param}`** - Optional path parameters matching with or without the segment. `/posts/{-$category}` matches both `/posts` and `/posts/tech`. Ranked lower than exact matches.
      
      ## Code-Based Routing
      
      Define routes entirely in code using `createRoute` and `createRootRoute`. Use when file-based routing does not fit.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/code-based-routing
      
      ```tsx
      import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
      
      const rootRoute = createRootRoute({
        component: () => <div><Outlet /></div>,
      })
      
      const aboutRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: 'about',
        component: () => <div>About</div>,
      })
      
      const postsRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: 'posts',
        component: PostsLayout,
      })
      
      const postsIndexRoute = createRoute({
        getParentRoute: () => postsRoute,
        path: '/',  // index route uses '/'
      })
      
      const postRoute = createRoute({
        getParentRoute: () => postsRoute,
        path: '$postId',
        loader: ({ params }) => fetchPost(params.postId),
      })
      
      // Pathless layout: use 'id' instead of 'path'
      const pathlessLayout = createRoute({
        getParentRoute: () => rootRoute,
        id: 'authLayout',
        component: AuthLayout,
      })
      
      // Non-nested: set parent to root with full path
      const postEditorRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: 'posts/$postId/edit',
      })
      
      // Build tree with .addChildren()
      const routeTree = rootRoute.addChildren([
        aboutRoute,
        postsRoute.addChildren([postsIndexRoute, postRoute]),
        postEditorRoute,
        pathlessLayout.addChildren([loginRoute, registerRoute]),
      ])
      
      const router = createRouter({ routeTree })
      ```
      
      Every route (except root) requires `getParentRoute` for type safety. Use code-based routing for runtime route generation, external config, incremental migration, or projects without bundler plugin support.
      
      ## Virtual File Routes
      
      Define route trees programmatically while referencing real files. Gives full control over organization without filesystem constraints.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/virtual-file-routes
      
      ### Setup
      
      ```ts
      // vite.config.ts
      import { tanstackRouter } from '@tanstack/router-plugin/vite'
      
      export default defineConfig({
        plugins: [
          tanstackRouter({
            target: 'react',
            virtualRouteConfig: './src/routes.ts',
          }),
          react(),
        ],
      })
      ```
      
      ### API Functions
      
      | Function | Purpose |
      |----------|---------|
      | `rootRoute(file, children)` | Creates root route pointing to a file |
      | `route(path, file, children)` | Creates route at path pointing to a file |
      | `index(file)` | Creates index route |
      | `layout(file, children)` | Creates pathless layout (optional ID as first arg) |
      | `physical(pathPrefix, directory)` | Mounts directory using standard file-based conventions |
      
      ### Full Example
      
      ```tsx
      // src/routes.ts
      import { rootRoute, route, index, layout, physical } from '@tanstack/virtual-file-routes'
      
      export const routes = rootRoute('root.tsx', [
        index('index.tsx'),
        layout('pathlessLayout.tsx', [
          route('/dashboard', 'app/dashboard.tsx', [
            index('app/dashboard-index.tsx'),
            route('/invoices', 'app/dashboard-invoices.tsx', [
              index('app/invoices-index.tsx'),
              route('$id', 'app/invoice-detail.tsx'),
            ]),
          ]),
          physical('/posts', 'posts'),  // mounts posts/ dir with file-based conventions
        ]),
      ])
      ```
      
      Routes without a file set a common path prefix: `route('/hello', [route('/world', 'world.tsx')])`.
      
      Use `physical('features')` (no path prefix) to merge a directory at the current level.
      
      ### `__virtual.ts` - Inline Virtual Configuration
      
      Drop a `__virtual.ts` in any directory within a file-based route tree to switch to virtual config for that subtree:
      
      ```tsx
      // src/routes/posts/__virtual.ts
      import { defineVirtualSubtreeConfig, index, route } from '@tanstack/virtual-file-routes'
      
      export default defineVirtualSubtreeConfig([
        index('home.tsx'),
        route('$id', 'details.tsx'),
      ])
      ```
      
      Virtual and file-based routing can be nested as many levels deep as needed.
      
      ## Layout Routes
      
      Layout routes wrap child routes with shared UI, loaders, search param validation, error boundaries, and context.
      
      **Path-based layout** - A route with children uses `<Outlet />` to render child content:
      
      ```tsx
      // src/routes/app.tsx
      import { Outlet, createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/app')({
        component: () => (
          <div>
            <h1>App Layout</h1>
            <Outlet />
          </div>
        ),
      })
      ```
      
      **Pathless layout** - Wraps children without adding a URL segment. Use `_` prefix in file-based or `id` in code-based:
      
      ```tsx
      // src/routes/_authenticated.tsx
      export const Route = createFileRoute('/_authenticated')({
        beforeLoad: async ({ context }) => {
          if (!context.auth.isAuthenticated) {
            throw redirect({ to: '/login' })
          }
        },
        component: () => <Outlet />,
      })
      ```
      
      ## Route Masking
      
      Displays a different URL in the browser than the one actually navigated to. When shared or reloaded, the displayed (masked) URL is used.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/route-masking
      
      ### Imperative (Link / navigate)
      
      ```tsx
      <Link
        to="/photos/$photoId/modal"
        params={{ photoId: '5' }}
        mask={{ to: '/photos/$photoId', params: { photoId: '5' } }}
      >
        Open Photo
      </Link>
      ```
      
      ```tsx
      const navigate = useNavigate()
      navigate({
        to: '/photos/$photoId/modal',
        params: { photoId: '5' },
        mask: { to: '/photos/$photoId', params: { photoId: '5' } },
      })
      ```
      
      ### Declarative (createRouteMask)
      
      ```tsx
      import { createRouteMask, createRouter } from '@tanstack/react-router'
      
      const photoModalMask = createRouteMask({
        routeTree,
        from: '/photos/$photoId/modal',
        to: '/photos/$photoId',
        params: (prev) => ({ photoId: prev.photoId }),
      })
      
      const router = createRouter({ routeTree, routeMasks: [photoModalMask] })
      ```
      
      ### unmaskOnReload
      
      By default, masked URLs persist through reloads. To unmask: pass `unmaskOnReload: true` on Link, createRouteMask, or as a router default.
      
      Route masking stores the actual target in `location.state.__tempLocation`. When copied/shared, mask data is lost and the displayed URL is used directly.
      
      ## Navigation Blocking
      
      Prevents route transitions for unsaved form data or in-progress operations.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/navigation-blocking
      
      ### useBlocker Hook
      
      ```tsx
      import { useBlocker } from '@tanstack/react-router'
      
      function EditForm() {
        const [formIsDirty, setFormIsDirty] = useState(false)
      
        useBlocker({
          shouldBlockFn: () => {
            if (!formIsDirty) return false
            return !confirm('Are you sure you want to leave?')
          },
        })
      }
      ```
      
      ### Custom UI with withResolver
      
      ```tsx
      const { proceed, reset, status } = useBlocker({
        shouldBlockFn: () => formIsDirty,
        withResolver: true,
      })
      
      // Render custom dialog when status === 'blocked'
      // Call proceed() to allow navigation, reset() to cancel
      ```
      
      ### shouldBlockFn with Type-Safe Locations
      
      ```tsx
      useBlocker({
        shouldBlockFn: ({ current, next }) => {
          return current.routeId === '/foo' && next.fullPath === '/bar/$id'
        },
        withResolver: true,
      })
      ```
      
      ### enableBeforeUnload
      
      Controls the browser's native beforeunload dialog independently:
      
      ```tsx
      useBlocker({
        shouldBlockFn: () => !formIsDirty ? false : !confirm('Leave?'),
        enableBeforeUnload: formIsDirty,  // or () => formIsDirty
      })
      ```
      
      ### Block Component
      
      Declarative alternative:
      
      ```tsx
      <Block shouldBlockFn={() => formIsDirty} enableBeforeUnload={formIsDirty} withResolver>
        {({ status, proceed, reset }) => (
          status === 'blocked' && <ConfirmDialog onConfirm={proceed} onCancel={reset} />
        )}
      </Block>
      ```
      
      ## URL Rewrites
      
      Bidirectional URL transformation between browser display and router interpretation.
      
      > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/url-rewrites
      
      ### Basic Configuration
      
      ```tsx
      const router = createRouter({
        routeTree,
        rewrite: {
          input: ({ url }) => { /* browser URL -> router internal */ return url },
          output: ({ url }) => { /* router internal -> browser URL */ return url },
        },
      })
      ```
      
      Rewrite functions receive a `URL` object and return the mutated `url`, a new `URL`, a string href, or `undefined` to skip.
      
      ### i18n Locale Prefix
      
      ```tsx
      const router = createRouter({
        routeTree,
        rewrite: {
          input: ({ url }) => {
            const segments = url.pathname.split('/').filter(Boolean)
            if (segments[0] && locales.includes(segments[0])) {
              url.pathname = '/' + segments.slice(1).join('/') || '/'
            }
            return url
          },
          output: ({ url }) => {
            url.pathname = `/${getLocale()}${url.pathname === '/' ? '' : url.pathname}`
            return url
          },
        },
      })
      ```
      
      ### Subdomain Routing
      
      ```tsx
      rewrite: {
        input: ({ url }) => {
          const subdomain = url.hostname.split('.')[0]
          if (subdomain === 'admin') url.pathname = '/admin' + url.pathname
          return url
        },
        output: ({ url }) => {
          if (url.pathname.startsWith('/admin')) {
            url.hostname = 'admin.example.com'
            url.pathname = url.pathname.replace(/^\/admin/, '') || '/'
          }
          return url
        },
      }
      ```
      
      ### Composing Rewrites
      
      ```tsx
      import { composeRewrites } from '@tanstack/react-router'
      const router = createRouter({ routeTree, rewrite: composeRewrites([localeRewrite, legacyRewrite]) })
      ```
      
      Input rewrites execute first-to-last. Output rewrites execute last-to-first (reverse).
      
      ### location.href vs location.publicHref
      
      - `location.href` - Internal URL (after input rewrite), used for routing
      - `location.publicHref` - External URL (after output rewrite), shown in browser
      
      Use `publicHref` for sharing links, canonical URLs, analytics, and clipboard operations.
      
      ## History Types
      
      TanStack Router supports three history types from `@tanstack/history`:
      
      | Type | Function | Use Case |
      |------|----------|----------|
      | Browser | `createBrowserHistory()` | Default. Uses browser History API |
      | Hash | `createHashHistory()` | Servers without URL rewriting support |
      | Memory | `createMemoryHistory()` | Testing, SSR, non-browser environments |
      
      ```tsx
      import { createMemoryHistory, createRouter } from '@tanstack/react-router'
      
      const memoryHistory = createMemoryHistory({ initialEntries: ['/'] })
      const router = createRouter({ routeTree, history: memoryHistory })
      ```
      
      ## Route Types Summary
      
      | Route Type | File Convention | URL Behavior | Component Behavior |
      |-----------|----------------|-------------|-------------------|
      | Root | `__root.tsx` | Always matches | Always rendered, wraps all routes |
      | Index | `index.tsx` | Exact parent match | Rendered when parent matches exactly |
      | Static | `about.tsx` | `/about` | Rendered at exact path |
      | Dynamic | `$postId.tsx` | `/123` (captures param) | Receives params via `useParams()` |
      | Splat | `$.tsx` | Captures all remaining | Receives `_splat` param |
      | Optional | `{-$param}.tsx` | With or without segment | Param `undefined` when absent |
      | Layout | `posts.tsx` + `posts/` | Matches path prefix | Wraps children via `<Outlet />` |
      | Pathless Layout | `_layout.tsx` | No URL segment consumed | Wraps children without URL impact |
      | Non-Nested | `posts_.$id.edit.tsx` | Matches full path | Renders outside parent layout |
      | Excluded | `-helpers.tsx` | Not a route | Ignored by router, for colocation |
      | Group | `(auth)/login.tsx` | Directory ignored | Organizational only |
      
      ## Route Lifecycle Callbacks & Remounting
      
      Routes expose transition hooks and control over when their component remounts.
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        onEnter: (match) => analytics.view(match.params.postId),  // matched after not being matched previously
        onStay: (match) => {},                                    // still matched across a navigation
        onLeave: (match) => cleanup(match.params.postId),         // no longer matched
      
        // By default a param change (/posts/1 -> /posts/2) reuses the component instance and just re-runs the loader.
        // Return a different value to force a full remount (reset local state, re-run effects):
        remountDeps: ({ params }) => params.postId,
      })
      ```
      
      Use `remountDeps` when a route's component holds per-item local state that must reset on param change; omit it to keep the cheaper reuse-and-reload default.
      
      ## View Transitions
      
      TanStack Router integrates the browser [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for animated navigations. Enable per-navigation or globally.
      
      ```tsx
      // Per navigation
      <Link to="/posts/$postId" params={{ postId: '1' }} viewTransition>Post</Link>
      navigate({ to: '/posts', viewTransition: true })
      
      // Globally for every navigation
      const router = createRouter({ routeTree, defaultViewTransition: true })
      
      // Scope named transitions with the object form (types via ViewTransitionOptions)
      <Link to="/posts" viewTransition={{ types: ['slide-left'] }}>Posts</Link>
      ```
      
      Falls back to an instant navigation in browsers without `document.startViewTransition`; style the animation with the standard `::view-transition-*` CSS pseudo-elements.
      
      ## Best Practices
      
      1. **Prefer file-based routing for most projects.** It provides automatic code splitting, type-safe route generation, and eliminates manual route tree assembly. Use code-based routing only when runtime generation or non-standard tooling requires it.
      
      2. **Mix flat and directory structures intentionally.** Use directories for route groups with multiple siblings. Use dot notation for isolated deep routes (e.g., `posts.$postId.edit.tsx`).
      
      3. **Use pathless layouts for shared UI without URL segments.** Common patterns include authentication wrappers (`_auth`), dashboard shells (`_dashboard`), and sidebar layouts. Name descriptively since the `_` prefix ID must be unique.
      
      4. **Colocate route-specific code with the `-` prefix.** Place helper components, utilities, and data-fetching logic next to route files using `-` prefixed names. They are ignored by the router but accessible via standard imports.
      
      5. **Use route masking for modal patterns.** When a modal has its own route for deep-linking but should display a simpler URL, masking keeps URLs clean. Set `unmaskOnReload: true` if masks should not persist across reloads.
      
      6. **Use `enableBeforeUnload` intentionally.** Only trigger the native beforeunload dialog for genuinely unsaved data. Pair `shouldBlockFn` with `enableBeforeUnload` tied to the same dirty state.
      
      7. **Use virtual file routes for custom project structures.** Map existing file organizations to routes with full programmatic control while retaining file-based routing performance benefits.
      
      8. **Use `publicHref` for external-facing URLs.** When URL rewrites are active, always use `location.publicHref` for sharing, canonical URLs, and analytics.
      
    • search-params.md 21.3 KB
      # Search Params
      
      TanStack Router treats URL search params as first-class application state - typed, validated, and managed with the same DX you expect from dedicated state management libraries.
      
      Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/search-params
      
      ## Search Params as State
      
      Search params are the "OG" state manager - global state living inside the URL. TanStack Router builds on this with JSON-first serialization that goes far beyond `URLSearchParams`:
      
      - Automatic JSON parsing/serialization of nested structures
      - Type-safe validation at the route level
      - Structural sharing to preserve referential identity between navigations
      - First-level values stay URLSearchParams-compatible while nested values are JSON-encoded
      
      ```tsx
      <Link
        to="/shop"
        search={{
          pageIndex: 3,
          includeCategories: ['electronics', 'gifts'],
          sortBy: 'price',
          desc: true,
        }}
      />
      // URL: /shop?pageIndex=3&includeCategories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&desc=true
      // Parsed back: { pageIndex: 3, includeCategories: ["electronics", "gifts"], sortBy: "price", desc: true }
      ```
      
      First-level primitives (numbers, booleans) are preserved as actual types, not strings. Nested objects and arrays are JSON-stringified. The first level remains flat for compatibility with tools that read `URLSearchParams`.
      
      ## Defining Search Params
      
      All definitions start with the `validateSearch` option on a route. It receives raw JSON-parsed search params and must return a typed object.
      
      ### Inline Validation
      
      ```tsx
      // src/routes/shop/products.tsx
      type ProductSearch = {
        page: number
        filter: string
        sort: 'newest' | 'oldest' | 'price'
      }
      
      export const Route = createFileRoute('/shop/products')({
        validateSearch: (search: Record<string, unknown>): ProductSearch => ({
          page: Number(search?.page ?? 1),
          filter: (search.filter as string) || '',
          sort: (search.sort as ProductSearch['sort']) || 'newest',
        }),
      })
      ```
      
      ### Zod (with .catch() Shorthand)
      
      Zod schemas work directly since `validateSearch` accepts objects with a `parse` property:
      
      ```tsx
      import { z } from 'zod'
      
      const productSearchSchema = z.object({
        page: z.number().catch(1),
        filter: z.string().catch(''),
        sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
      })
      
      export const Route = createFileRoute('/shop/products')({
        validateSearch: productSearchSchema, // shorthand for (s) => productSearchSchema.parse(s)
      })
      ```
      
      `.catch()` silently provides a fallback when validation fails. `.default()` throws an error that triggers `onError`/`errorComponent` (with `error.routerCode === 'VALIDATE_SEARCH'`).
      
      ### Zod Adapter for Input/Output Types
      
      When using `.default()`, Zod's input type differs from output. The `zodValidator` adapter handles this so navigation does not require defaulted params:
      
      ```tsx
      import { zodValidator } from '@tanstack/zod-adapter'
      import { z } from 'zod'
      
      const productSearchSchema = z.object({
        page: z.number().default(1),
        filter: z.string().default(''),
        sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
      })
      
      export const Route = createFileRoute('/shop/products/')({
        validateSearch: zodValidator(productSearchSchema),
      })
      
      // This Link is valid - search params are optional because of defaults
      <Link to="/shop/products" />
      ```
      
      The `fallback` helper retains types while providing fallback values (unlike `.catch()` which makes fields `unknown`):
      
      ```tsx
      import { fallback, zodValidator } 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),
      })
      ```
      
      You can configure which type to use for navigation vs reading:
      
      ```tsx
      validateSearch: zodValidator({
        schema: productSearchSchema,
        input: 'output',  // Use output type for navigation
        output: 'input',  // Use input type for reading
      })
      ```
      
      ### Valibot (Standard Schema - No Adapter Needed)
      
      Valibot 1.0+ implements Standard Schema, so it works directly:
      
      ```tsx
      import * as v from 'valibot'
      
      const productSearchSchema = v.object({
        page: v.optional(v.fallback(v.number(), 1), 1),
        filter: v.optional(v.fallback(v.string(), ''), ''),
        sort: v.optional(
          v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'),
          'newest',
        ),
      })
      
      export const Route = createFileRoute('/shop/products/')({
        validateSearch: productSearchSchema,
      })
      ```
      
      ### ArkType (Standard Schema - No Adapter Needed)
      
      ```tsx
      import { type } from 'arktype'
      
      const productSearchSchema = type({
        page: 'number = 1',
        filter: 'string = ""',
        sort: '"newest" | "oldest" | "price" = "newest"',
      })
      
      export const Route = createFileRoute('/shop/products/')({
        validateSearch: productSearchSchema,
      })
      ```
      
      ## Reading Search Params
      
      ### useSearch in Components
      
      ```tsx
      export const Route = createFileRoute('/shop/products')({
        validateSearch: productSearchSchema,
      })
      
      function ProductList() {
        const { page, filter, sort } = Route.useSearch()
        return <div>Page {page}, Filter: {filter}, Sort: {sort}</div>
      }
      ```
      
      ### Fine-Grained Selectors with select
      
      Use `select` to subscribe to a subset of search params. The component only re-renders when the selected value changes:
      
      ```tsx
      // Only re-renders when page changes
      const page = Route.useSearch({ select: (s) => s.page })
      ```
      
      When `select` returns a new object, enable structural sharing to prevent unnecessary re-renders:
      
      ```tsx
      const filterState = Route.useSearch({
        select: (s) => ({ filter: s.filter, label: `Filtering by: ${s.filter}` }),
        structuralSharing: true,
      })
      ```
      
      Enable globally via `createRouter({ routeTree, defaultStructuralSharing: true })`. Note: structural sharing only works with JSON-compatible data (not class instances).
      
      ### Outside Route Components
      
      Use `getRouteApi` to avoid importing the route (prevents circular deps in code-split routes):
      
      ```tsx
      import { getRouteApi } from '@tanstack/react-router'
      
      const routeApi = getRouteApi('/shop/products')
      
      function ProductSidebar() {
        const { filter, sort } = routeApi.useSearch()
        return <div>Filter: {filter}, Sort: {sort}</div>
      }
      ```
      
      Or use `useSearch` with `from` for type safety, or `strict: false` for loose access:
      
      ```tsx
      // Type-safe: requires from
      const search = useSearch({ from: '/shop/products' })
      
      // Loose: all params are T | undefined
      const search = useSearch({ strict: false })
      ```
      
      ### In Loaders via loaderDeps
      
      Only extract params your loader actually uses - returning the entire search object causes unnecessary cache invalidation:
      
      ```tsx
      export const Route = createFileRoute('/posts')({
        validateSearch: z.object({
          offset: z.number().catch(0),
          limit: z.number().catch(20),
          viewMode: z.enum(['grid', 'list']).catch('list'),
        }),
        // Only extract what the loader needs
        loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }),
        loader: ({ deps: { offset, limit } }) => fetchPosts({ offset, limit }),
      })
      // BAD: loaderDeps: ({ search }) => search  // reloads when viewMode changes too
      ```
      
      ## Writing Search Params
      
      ### Link with search Prop
      
      ```tsx
      // Replace all search params
      <Link to="/shop/products" search={{ page: 1, filter: '', sort: 'newest' }}>
        Reset
      </Link>
      
      // Functional update - preserves other params
      <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>
        Next Page
      </Link>
      
      // Generic component on multiple routes: use to="." for loose types
      <Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
        Next Page
      </Link>
      
      // Component in a specific subtree: specify from
      <Link from="/posts" to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
        Next Page
      </Link>
      ```
      
      ### useNavigate
      
      For programmatic updates in event handlers:
      
      ```tsx
      const navigate = useNavigate({ from: '/shop/products' })
      
      // Functional update
      navigate({ search: (prev) => ({ ...prev, sort: 'price', page: 1 }) })
      
      // Full replacement
      navigate({ search: { page: 1, filter: '', sort: 'newest' } })
      ```
      
      ### router.navigate and Navigate Component
      
      ```tsx
      // Outside React components
      router.navigate({ to: '/shop/products', search: (prev) => ({ ...prev, page: 1 }) })
      
      // Declarative redirect inside a component
      <Navigate to="/shop/products" search={{ page: 1, filter: '', sort: 'newest' }} />
      ```
      
      ## Search Middlewares
      
      Search middlewares transform search params when generating link hrefs and during navigation after validation. Defined in `search.middlewares` on a route.
      
      ### Custom Middleware
      
      Each middleware receives `{ search, next }`. `search` is the current params, `next` produces the result from downstream:
      
      ```tsx
      export const Route = createRootRoute({
        validateSearch: zodValidator(z.object({ rootValue: z.string().optional() })),
        search: {
          middlewares: [
            ({ search, next }) => {
              const result = next(search)
              return { rootValue: search.rootValue, ...result }  // retain unless overridden
            },
          ],
        },
      })
      ```
      
      ### retainSearchParams
      
      Preserves specified params across navigations. If a link explicitly sets a param, that value wins.
      
      ```tsx
      import { retainSearchParams } from '@tanstack/react-router'
      
      // Retain specific keys
      search: { middlewares: [retainSearchParams(['rootValue', 'locale'])] }
      
      // Retain ALL current params
      search: { middlewares: [retainSearchParams(true)] }
      ```
      
      ### stripSearchParams
      
      Removes params from URLs when they match defaults, keeping URLs clean.
      
      ```tsx
      import { stripSearchParams } from '@tanstack/react-router'
      
      // Strip by default values (deep equality comparison)
      const defaults = { one: 'abc', two: 'xyz' }
      search: { middlewares: [stripSearchParams(defaults)] }
      // URL: /hello when one='abc' and two='xyz'
      // URL: /hello?one=changed when one differs
      
      // Strip specific keys (always remove, regardless of value)
      search: { middlewares: [stripSearchParams(['hello'])] }
      
      // Strip ALL params (only works when no params are required)
      search: { middlewares: [stripSearchParams(true)] }
      ```
      
      ### Chaining Middlewares
      
      Middlewares execute in order. Combine for complex behaviors:
      
      ```tsx
      export const Route = createFileRoute('/search')({
        validateSearch: zodValidator(z.object({
          retainMe: z.string().optional(),
          arrayWithDefaults: z.string().array().default(['foo', 'bar']),
          required: z.string(),
        })),
        search: {
          middlewares: [
            retainSearchParams(['retainMe']),
            stripSearchParams({ arrayWithDefaults: ['foo', 'bar'] }),
          ],
        },
      })
      ```
      
      ## Search Param Inheritance
      
      Child routes automatically inherit and merge parent search params. Types merge down the tree:
      
      ```tsx
      // src/routes/shop/products.tsx - parent defines page, filter, sort
      export const Route = createFileRoute('/shop/products')({
        validateSearch: z.object({
          page: z.number().catch(1),
          filter: z.string().catch(''),
          sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
        }),
      })
      
      // src/routes/shop/products/$productId.tsx - child has full access
      export const Route = createFileRoute('/shop/products/$productId')({
        validateSearch: z.object({
          tab: z.enum(['details', 'reviews', 'related']).catch('details'),
        }),
        beforeLoad: ({ search }) => {
          search.page   // number (from parent)
          search.filter // string (from parent)
          search.sort   // 'newest' | 'oldest' | 'price' (from parent)
          search.tab    // 'details' | 'reviews' | 'related' (own)
        },
      })
      ```
      
      ## Complex Types
      
      ### Arrays and Nested Objects
      
      ```tsx
      import { fallback, zodValidator } from '@tanstack/zod-adapter'
      import { z } from 'zod'
      
      const searchSchema = z.object({
        categories: fallback(z.array(z.string()), []).default([]),
        tags: fallback(z.array(z.number()), []).default([]),
        priceRange: fallback(
          z.object({ min: z.number(), max: z.number() }),
          { min: 0, max: 1000 },
        ).default({ min: 0, max: 1000 }),
      })
      
      export const Route = createFileRoute('/products/')({
        validateSearch: zodValidator(searchSchema),
      })
      // <Link to="/products" search={{ categories: ['electronics'], tags: [1, 2], priceRange: { min: 10, max: 500 } }} />
      ```
      
      ### Dates as Strings
      
      Search params must be JSON-serializable, so store dates as ISO strings:
      
      ```tsx
      const searchSchema = z.object({
        startDate: fallback(z.string(), '').default(''),
        endDate: fallback(z.string(), '').default(''),
      })
      
      // In component: parse to Date when needed
      const start = startDate ? new Date(startDate) : null
      ```
      
      ### Enum/Union Types
      
      ```tsx
      const viewModes = ['grid', 'list', 'table'] as const
      
      const searchSchema = z.object({
        view: fallback(z.enum(viewModes), 'grid').default('grid'),
        columns: fallback(z.array(z.string()), ['name', 'date']).default(['name', 'date']),
      })
      ```
      
      ## Custom Serialization
      
      Replace the default JSON serializer at the router level via `parseSearch`/`stringifySearch`. The default behavior is `parseSearchWith(JSON.parse)` and `stringifySearchWith(JSON.stringify)`.
      
      ### query-string
      
      ```tsx
      import qs from 'query-string'
      
      const router = createRouter({
        routeTree,
        stringifySearch: stringifySearchWith((value) => qs.stringify(value)),
        parseSearch: parseSearchWith((value) => qs.parse(value)),
      })
      // Produces: ?page=1&sort=asc&filters=author%3Dtanner%26min_words%3D800
      ```
      
      ### JSURL2
      
      Compresses URLs while maintaining readability:
      
      ```tsx
      import { parse, stringify } from 'jsurl2'
      
      const router = createRouter({
        routeTree,
        parseSearch: parseSearchWith(parse),
        stringifySearch: stringifySearchWith(stringify),
      })
      // Produces: ?page=1&sort=asc&filters=(author~tanner~min*_words~800)~
      ```
      
      ### Base64 Encoding
      
      For maximum compatibility across browsers and URL unfurlers. Use safe binary encoding utilities that handle non-UTF8 characters (not raw `atob`/`btoa`):
      
      ```tsx
      const router = createRouter({
        routeTree,
        parseSearch: parseSearchWith((value) => JSON.parse(decodeFromBinary(value))),
        stringifySearch: stringifySearchWith((value) => encodeToBinary(JSON.stringify(value))),
      })
      
      function decodeFromBinary(str: string): string {
        return decodeURIComponent(
          Array.prototype.map
            .call(atob(str), (c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
            .join(''),
        )
      }
      
      function encodeToBinary(str: string): string {
        return btoa(
          encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
            String.fromCharCode(parseInt(p1, 16)),
          ),
        )
      }
      ```
      
      ## Sharing Search Params Across Routes
      
      ### Global Params via Root Route
      
      Define on the root with `retainSearchParams` so params persist everywhere:
      
      ```tsx
      // src/routes/__root.tsx
      export const Route = createRootRoute({
        validateSearch: zodValidator(z.object({
          locale: z.enum(['en', 'es', 'fr']).optional(),
          debug: z.boolean().optional(),
        })),
        search: { middlewares: [retainSearchParams(['locale', 'debug'])] },
      })
      // Every route/component can access locale and debug; they survive navigations
      ```
      
      ### Cross-Route Retention on Layout Routes
      
      Use on a layout route so child navigations preserve shared state:
      
      ```tsx
      // src/routes/dashboard.tsx - navigating between children preserves dateRange/teamId
      export const Route = createFileRoute('/dashboard')({
        validateSearch: zodValidator(z.object({
          dateRange: z.enum(['7d', '30d', '90d', '1y']).optional(),
          teamId: z.string().optional(),
        })),
        search: { middlewares: [retainSearchParams(['dateRange', 'teamId'])] },
      })
      ```
      
      ### Combining Retention and Stripping
      
      ```tsx
      const analyticsDefaults = { metric: 'pageviews', interval: 'daily' }
      
      export const Route = createFileRoute('/dashboard/analytics')({
        validateSearch: zodValidator(z.object({
          metric: z.string().default(analyticsDefaults.metric),
          interval: z.enum(['hourly', 'daily', 'weekly']).default(analyticsDefaults.interval),
          compareWith: z.string().optional(),
        })),
        search: {
          middlewares: [
            retainSearchParams(['compareWith']),       // persist across navigations
            stripSearchParams(analyticsDefaults),       // clean URL when at defaults
          ],
        },
      })
      ```
      
      ## Common Patterns
      
      ### Pagination
      
      ```tsx
      const defaults = { page: 1, perPage: 20 }
      
      export const Route = createFileRoute('/posts')({
        validateSearch: zodValidator(z.object({
          page: fallback(z.number().int().positive(), defaults.page).default(defaults.page),
          perPage: fallback(z.number().int().positive().max(100), defaults.perPage).default(defaults.perPage),
        })),
        search: { middlewares: [stripSearchParams(defaults)] },
        loaderDeps: ({ search: { page, perPage } }) => ({ page, perPage }),
        loader: ({ deps }) => fetchPosts(deps),
        component: function PostList() {
          const { page } = Route.useSearch()
          return (
            <div>
              <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: Math.max(1, prev.page - 1) })}>
                Previous
              </Link>
              <span>Page {page}</span>
              <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>
                Next
              </Link>
            </div>
          )
        },
      })
      ```
      
      ### Filters and Sorting
      
      ```tsx
      const defaults = { search: '', category: 'all', sortBy: 'name' as const, sortOrder: 'asc' as const }
      
      export const Route = createFileRoute('/products')({
        validateSearch: zodValidator(z.object({
          search: fallback(z.string(), '').default(''),
          category: fallback(z.string(), 'all').default('all'),
          sortBy: fallback(z.enum(['name', 'price', 'rating']), 'name').default('name'),
          sortOrder: fallback(z.enum(['asc', 'desc']), 'asc').default('asc'),
        })),
        search: { middlewares: [stripSearchParams(defaults)] },
        loaderDeps: ({ search }) => search,
        loader: ({ deps }) => fetchProducts(deps),
        component: function ProductsPage() {
          const search = Route.useSearch()
          const navigate = useNavigate({ from: Route.fullPath })
          return (
            <div>
              <input
                value={search.search}
                onChange={(e) => navigate({ search: (prev) => ({ ...prev, search: e.target.value }) })}
              />
              <Link from={Route.fullPath} search={defaults}>Reset</Link>
            </div>
          )
        },
      })
      ```
      
      ### Modal State via Search Params
      
      ```tsx
      export const Route = createFileRoute('/users')({
        validateSearch: zodValidator(z.object({
          editUserId: fallback(z.string(), '').default(''),
          showCreateModal: fallback(z.boolean(), false).default(false),
        })),
        search: { middlewares: [stripSearchParams({ editUserId: '', showCreateModal: false })] },
        component: function UsersPage() {
          const { editUserId, showCreateModal } = Route.useSearch()
          const navigate = useNavigate({ from: Route.fullPath })
          const closeModals = () => navigate({
            search: (prev) => ({ ...prev, editUserId: '', showCreateModal: false }),
          })
          return (
            <div>
              <Link from={Route.fullPath} search={(prev) => ({ ...prev, showCreateModal: true })}>
                Create User
              </Link>
              {showCreateModal && <CreateUserModal onClose={closeModals} />}
              {editUserId && <EditUserModal userId={editUserId} onClose={closeModals} />}
            </div>
          )
        },
      })
      ```
      
      ### Tab State
      
      ```tsx
      const tabs = ['general', 'security', 'notifications'] as const
      
      export const Route = createFileRoute('/settings')({
        validateSearch: zodValidator(z.object({
          tab: fallback(z.enum(tabs), 'general').default('general'),
        })),
        search: { middlewares: [stripSearchParams({ tab: 'general' as const })] },
        component: function SettingsPage() {
          const { tab } = Route.useSearch()
          return (
            <nav>
              {tabs.map((t) => (
                <Link key={t} from={Route.fullPath} search={{ tab: t }}>{t}</Link>
              ))}
            </nav>
          )
        },
      })
      ```
      
      ## Best Practices
      
      1. **Always validate search params.** Never trust raw URL input. Use `validateSearch` with `.catch()` or `fallback()` to provide sensible defaults so malformed URLs do not break the user experience.
      
      2. **Use schema-based validation with adapters.** Prefer Zod with `zodValidator` or Valibot/ArkType via Standard Schema over manual validation. This gives you correct input/output type inference for both navigation and reading.
      
      3. **Use `fallback()` + `.default()` with the Zod adapter.** Plain `.catch()` loses type information. The `fallback` helper from `@tanstack/zod-adapter` preserves types while handling malformed values.
      
      4. **Strip default values from URLs.** Use `stripSearchParams` to keep URLs clean. Users should only see params that differ from defaults.
      
      5. **Retain cross-cutting params with `retainSearchParams`.** For params like `locale`, `debug`, or `theme` that should survive navigations, define them on a parent route with `retainSearchParams`.
      
      6. **Be selective with `loaderDeps`.** Only extract search params your loader actually uses. Returning the entire search object causes unnecessary cache invalidation when unrelated params change.
      
      7. **Use `select` for fine-grained subscriptions.** When a component only needs one search param, use `Route.useSearch({ select })` to prevent re-renders when other params change. Enable `structuralSharing: true` when selectors return objects.
      
      8. **Prefer functional updates for search.** Use `search={(prev) => ({ ...prev, page: prev.page + 1 })}` instead of replacing the entire object. This preserves other params and avoids dropping state.
      
      ## Related Documentation
      
      - Search Params Guide: https://tanstack.com/router/latest/docs/framework/react/guide/search-params
      - Custom Serialization: https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization
      - Render Optimizations: https://tanstack.com/router/latest/docs/framework/react/guide/render-optimizations
      - Data Loading (loaderDeps): https://tanstack.com/router/latest/docs/framework/react/guide/data-loading
      - Type Safety: https://tanstack.com/router/latest/docs/framework/react/guide/type-safety
      
    • server-functions.md 24.7 KB
      # Server Functions
      
      Server functions let you define server-only logic that can be called from anywhere in your application - route loaders, components, hooks, event handlers, or other server functions. They run on the server but can be invoked from client code seamlessly through an auto-generated RPC layer.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/server-functions
      
      ## createServerFn Basics
      
      Create server functions with `createServerFn()`. The default HTTP method is GET; use POST for mutations.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      
      // GET request (default) - for reading data
      export const getServerTime = createServerFn().handler(async () => {
        return new Date().toISOString()
      })
      
      // POST request - for mutations
      export const saveData = createServerFn({ method: 'POST' }).handler(
        async () => {
          await db.items.create({ title: 'New item' })
          return { success: true }
        },
      )
      ```
      
      Server functions accept a single `data` parameter. All parameters must be passed through this object since data crosses the network boundary.
      
      ```tsx
      export const greetUser = createServerFn({ method: 'GET' })
        .handler(async ({ data }) => {
          return `Hello, ${data.name}!`
        })
      
      // Calling the function
      const greeting = await greetUser({ data: { name: 'John' } })
      ```
      
      ## Input Validation
      
      Since server functions cross the network boundary, validating input ensures type safety and runtime correctness. TanStack Start supports inline validators and schema-library adapters.
      
      > The method is `.validator()`. The older `.inputValidator()` spelling is deprecated and the compiler now emits warnings for it (TanStack/router PR #7566).
      
      ### Inline Validator
      
      The simplest approach - pass a function that validates and returns typed data:
      
      ```tsx
      export const getUser = createServerFn({ method: 'GET' })
        .validator((data: { id: string }) => data)
        .handler(async ({ data }) => {
          // data is typed as { id: string }
          return db.users.findById(data.id)
        })
      ```
      
      ### Zod Validator (Recommended)
      
      The `@tanstack/zod-adapter` provides the `zodValidator` adapter for use with Zod schemas:
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import { zodValidator } from '@tanstack/zod-adapter'
      import { z } from 'zod'
      
      const CreatePostSchema = z.object({
        title: z.string().min(1).max(200),
        body: z.string().min(1),
        tags: z.array(z.string()).optional(),
      })
      
      export const createPost = createServerFn({ method: 'POST' })
        .validator(zodValidator(CreatePostSchema))
        .handler(async ({ data }) => {
          // data is fully typed and validated:
          // { title: string; body: string; tags?: string[] }
          return db.posts.create(data)
        })
      ```
      
      You can also pass a Zod schema directly to `validator` without the adapter:
      
      ```tsx
      export const createUser = createServerFn({ method: 'POST' })
        .validator(
          z.object({
            name: z.string().min(1),
            age: z.number().min(0),
          }),
        )
        .handler(async ({ data }) => {
          return `Created user: ${data.name}, age ${data.age}`
        })
      ```
      
      ### Other Validator Adapters
      
      Valibot and ArkType adapters follow the same pattern:
      
      ```tsx
      // Valibot - install @tanstack/valibot-adapter
      import { valibotValidator } from '@tanstack/valibot-adapter'
      import * as v from 'valibot'
      
      export const updateProfile = createServerFn({ method: 'POST' })
        .validator(valibotValidator(v.object({
          displayName: v.pipe(v.string(), v.minLength(1)),
          bio: v.optional(v.pipe(v.string(), v.maxLength(500))),
        })))
        .handler(async ({ data }) => db.profiles.update(data))
      
      // ArkType - install @tanstack/arktype-adapter
      import { arkTypeValidator } from '@tanstack/arktype-adapter'
      import { type } from 'arktype'
      
      export const searchItems = createServerFn({ method: 'GET' })
        .validator(arkTypeValidator(type({ query: 'string', page: 'number > 0' })))
        .handler(async ({ data }) => db.items.search(data.query, data.page))
      ```
      
      ### Serialization Checks (`strict` mode)
      
      Inputs and outputs cross the network boundary, so TypeScript checks they are serializable. This is the default `strict: true`. Opt out per function only when you know the runtime serialization layer can handle the value:
      
      ```tsx
      createServerFn({ strict: false })            // disable input + output checks
      createServerFn({ strict: { input: false } }) // disable input checks only
      createServerFn({ strict: { output: false } })// disable output checks only
      ```
      
      `strict: false` relaxes only the type-level checks - values still need to survive runtime serialization. Prefer the default.
      
      ## Calling Patterns
      
      Server functions can be called from four main contexts.
      
      ### From Route Loaders
      
      The most common pattern for data fetching. Loaders are isomorphic (run on both server and client), so wrapping data access in a server function keeps secrets and database logic server-only.
      
      ```tsx
      import { createFileRoute } from '@tanstack/react-router'
      import { createServerFn } from '@tanstack/react-start'
      
      const getPosts = createServerFn().handler(async () => {
        return db.posts.findMany({ orderBy: { createdAt: 'desc' } })
      })
      
      export const Route = createFileRoute('/posts')({
        loader: () => getPosts(),
        component: () => {
          const posts = Route.useLoaderData()
          return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
        },
      })
      ```
      
      ### From Components with useServerFn()
      
      The `useServerFn()` hook wraps a server function for use in components, especially with mutations and event handlers:
      
      ```tsx
      import { useServerFn } from '@tanstack/react-start'
      import { useMutation } from '@tanstack/react-query'
      
      const deletePost = createServerFn({ method: 'POST' })
        .validator((data: { id: string }) => data)
        .handler(async ({ data }) => {
          await db.posts.delete({ where: { id: data.id } })
          return { success: true }
        })
      
      function PostActions({ postId }: { postId: string }) {
        const deletePostFn = useServerFn(deletePost)
      
        const mutation = useMutation({
          mutationFn: () => deletePostFn({ data: { id: postId } }),
          onSuccess: () => {
            // handle success
          },
        })
      
        return (
          <button onClick={() => mutation.mutate()}>
            Delete Post
          </button>
        )
      }
      ```
      
      ### From Other Server Functions
      
      Server functions can compose by calling each other directly:
      
      ```tsx
      const getCurrentUser = createServerFn().handler(async () => {
        const session = await getSession()
        if (!session) return null
        return db.users.findById(session.userId)
      })
      
      const getUserPosts = createServerFn().handler(async () => {
        const user = await getCurrentUser()
        if (!user) throw redirect({ to: '/login' })
        return db.posts.findMany({ where: { authorId: user.id } })
      })
      ```
      
      ### From Event Handlers
      
      Call server functions directly from click handlers, form submissions, and other events:
      
      ```tsx
      function LikeButton({ postId }: { postId: string }) {
        const handleLike = async () => {
          await likePost({ data: { id: postId } })
        }
      
        return <button onClick={handleLike}>Like</button>
      }
      ```
      
      ### Testing / Invoking Directly (curl)
      
      A server function's route is a real HTTP endpoint, but a bare `curl` returns an empty `200` and never runs your handler - the handler only fires when the request carries the `x-tsr-serverFn: true` header (the client fetcher sets it automatically). To exercise one directly:
      
      ```bash
      curl -X POST 'http://localhost:3000/_serverFn/<serverFnId>' \
        -H 'x-tsr-serverFn: true' \
        -H 'content-type: application/json' \
        -d '{"data":{"id":"123"}}'
      ```
      
      Also note a thrown error still returns HTTP **200**; the error is framed in the response body (`$TSR/Error`), not the status code - assert on the body, not the status, in tests and monitoring.
      
      Access request/response utilities inside server function handlers via `@tanstack/react-start/server`.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import {
        getRequest,
        getRequestHeader,
        setResponseHeader,
        setResponseHeaders,
        setResponseStatus,
      } from '@tanstack/react-start/server'
      
      export const getCachedData = createServerFn({ method: 'GET' }).handler(
        async () => {
          // Access the full incoming Request object
          const request = getRequest()
      
          // Read a specific request header
          const authHeader = getRequestHeader('Authorization')
      
          // Set a single response header
          setResponseHeader('X-Request-Id', crypto.randomUUID())
      
          // Set multiple response headers via a Headers object
          setResponseHeaders(
            new Headers({
              'Cache-Control': 'public, max-age=300',
              'CDN-Cache-Control': 'max-age=3600, stale-while-revalidate=600',
            }),
          )
      
          // Set the HTTP status code
          setResponseStatus(200)
      
          return fetchExpensiveData()
        },
      )
      ```
      
      Available utilities:
      
      | Utility | Description |
      |---------|-------------|
      | `getRequest()` | Access the full `Request` object |
      | `getRequestHeader(name)` | Read a specific request header |
      | `setResponseHeader(name, value)` | Set a single response header |
      | `setResponseHeaders(headers)` | Set multiple response headers via `Headers` object |
      | `setResponseStatus(code)` | Set the HTTP status code |
      
      ### Cache-Control Safety
      
      `Cache-Control: public` tells every CDN/proxy between you and the user that the response may be served to anyone. The example above is safe *only* because it returns non-identity data. If a handler reads a session, cookie, or auth header - or branches on identity at all - `public` will cache one user's response and replay it to the next (cross-tenant data leak).
      
      ```tsx
      // Authenticated data - must NOT be 'public'
      export const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
        const session = await requireSession()
        setResponseHeaders(new Headers({
          'Cache-Control': 'private, max-age=60',   // only the user-agent may cache
          Vary: 'Cookie, Authorization',            // key any cache by identity, not URL alone
        }))
        return db.orders.findMany({ where: { userId: session.userId } })
      })
      
      // Sensitive data - opt out entirely:
      // setResponseHeaders(new Headers({ 'Cache-Control': 'no-store' }))
      ```
      
      ## Error Handling
      
      Server functions support standard error throws, redirects, and not-found responses. Errors thrown inside handlers are serialized and sent to the client.
      
      ### Error Serialization
      
      ```tsx
      export const riskyOperation = createServerFn({ method: 'POST' }).handler(
        async () => {
          const result = await externalApi.call()
      
          if (!result.ok) {
            throw new Error('External API failed')
          }
      
          return result.data
        },
      )
      
      // On the client, the error is deserialized
      try {
        await riskyOperation()
      } catch (error) {
        console.log(error.message) // "External API failed"
      }
      ```
      
      ### Redirects
      
      Use `redirect()` from `@tanstack/react-router` to redirect the user. Redirects thrown from server functions are handled automatically when called from route loaders or via `useServerFn()`.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import { redirect } from '@tanstack/react-router'
      
      export const requireAuth = createServerFn().handler(async () => {
        const user = await getCurrentUser()
      
        if (!user) {
          throw redirect({ to: '/login' })
        }
      
        return user
      })
      
      // Use in a loader - redirect is handled automatically
      export const Route = createFileRoute('/dashboard')({
        loader: () => requireAuth(),
      })
      ```
      
      ### Not Found
      
      Throw `notFound()` for missing resources. This triggers the nearest `notFoundComponent` in the route tree.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import { notFound } from '@tanstack/react-router'
      
      export const getPost = createServerFn()
        .validator((data: { id: string }) => data)
        .handler(async ({ data }) => {
          const post = await db.posts.findById(data.id)
      
          if (!post) {
            throw notFound()
          }
      
          return post
        })
      ```
      
      ## Streaming Responses
      
      Server functions can stream typed data to the client using `ReadableStream` or async generators. This is particularly useful for AI/chat use cases.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/streaming-data-from-server-functions
      
      ### Typed ReadableStream
      
      Return a `ReadableStream<T>` from a server function. The type parameter flows through to the client.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      
      type ChatChunk = {
        choices: Array<{
          delta: { content?: string }
          index: number
          finish_reason: string | null
        }>
      }
      
      const streamChat = createServerFn({ method: 'POST' })
        .validator((data: { prompt: string }) => data)
        .handler(async ({ data }) => {
          const aiResponse = await callAIProvider(data.prompt)
      
          const stream = new ReadableStream<ChatChunk>({
            async start(controller) {
              for await (const chunk of aiResponse) {
                controller.enqueue(chunk)
              }
              controller.close()
            },
          })
      
          return stream
        })
      ```
      
      Consuming the stream on the client with `getReader()`:
      
      ```tsx
      const response = await streamChat({ data: { prompt } })
      if (!response) return
      
      const reader = response.getReader()
      let done = false
      while (!done) {
        const { value, done: doneReading } = await reader.read()
        done = doneReading
        if (value) {
          // value is typed as ChatChunk
          const content = value.choices[0].delta.content
          if (content) setMessage((prev) => prev + content)
        }
      }
      ```
      
      ### Async Generators
      
      A cleaner alternative to `ReadableStream`. Use `async function*` as the handler:
      
      ```tsx
      const streamMessages = createServerFn({ method: 'POST' })
        .validator((data: { prompt: string }) => data)
        .handler(async function* ({ data }) {
          const messages = await generateAIResponse(data.prompt)
      
          for (const msg of messages) {
            await sleep(100) // simulate latency
            // Yielded values are typed and streamed to the client
            yield msg
          }
        })
      ```
      
      The client code is significantly simpler with `for await...of`:
      
      ```tsx
      const handleStream = useCallback(async () => {
        setMessages('')
        for await (const msg of await streamMessages({ data: { prompt } })) {
          const chunk = msg.choices[0].delta.content
          if (chunk) {
            setMessages((prev) => prev + chunk)
          }
        }
      }, [prompt])
      ```
      
      ## FormData Handling
      
      Server functions support native `FormData` as input. Validate and extract fields in the `validator`:
      
      ```tsx
      export const submitContactForm = createServerFn({ method: 'POST' })
        .validator((data) => {
          if (!(data instanceof FormData)) throw new Error('Expected FormData')
          const name = data.get('name')?.toString()
          const email = data.get('email')?.toString()
          const message = data.get('message')?.toString()
          if (!name || !email || !message) throw new Error('All fields are required')
          return { name, email, message }
        })
        .handler(async ({ data }) => {
          await sendEmail({ to: 'support@example.com', subject: `Contact from ${data.name}`, body: data.message })
          return { success: true }
        })
      ```
      
      Call from a component with `useServerFn`:
      
      ```tsx
      function ContactForm() {
        const submitFn = useServerFn(submitContactForm)
        return (
          <form onSubmit={async (e) => {
            e.preventDefault()
            await submitFn({ data: new FormData(e.currentTarget) })
          }}>
            <input name="name" required />
            <input name="email" type="email" required />
            <textarea name="message" required />
            <button type="submit">Send</button>
          </form>
        )
      }
      ```
      
      ## Execution Model
      
      Understanding how server functions execute across environments is critical for building secure TanStack Start applications.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/execution-model
      
      ### Isomorphic by Default
      
      All code in TanStack Start is isomorphic by default - included in both server and client bundles unless explicitly constrained. This includes route loaders, which execute on server during SSR AND on client during client-side navigation. Always wrap server-only logic (database access, secrets) in `createServerFn()`.
      
      ### Build-Time RPC Replacement
      
      The build process replaces server function implementations with RPC stubs in client bundles:
      
      1. You write a server function with `createServerFn().handler()`
      2. The compiler extracts the handler to a server-only module
      3. The client bundle gets a stub that makes a `fetch` call to the server
      4. The server locates the handler using a generated, stable function ID (SHA256 hash by default)
      
      ```tsx
      // What you write:
      import { getUsers } from './db/queries.server'
      export const fetchUsers = createServerFn().handler(async () => getUsers())
      
      // What the client build produces (conceptually):
      export const fetchUsers = createServerFn({ method: 'GET' }).handler(
        createClientRpc('sha256:abc123...')
      )
      // The server-only import (getUsers) is removed entirely
      ```
      
      Server functions can be safely statically imported anywhere, including client components. **Avoid dynamic imports** - the build process cannot replace them with RPC stubs.
      
      You can customize function ID generation via `serverFns.generateFunctionId` in the TanStack Start Vite plugin config (experimental).
      
      ## Environment Functions
      
      Environment functions control function execution based on the runtime environment. They complement server functions for cases where you need different implementations per environment rather than RPC calls.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/environment-functions
      
      ```tsx
      import {
        createIsomorphicFn,
        createServerOnlyFn,
        createClientOnlyFn,
      } from '@tanstack/react-start'
      
      // Different implementations per environment
      const getDeviceInfo = createIsomorphicFn()
        .server(() => ({ type: 'server', platform: process.platform }))
        .client(() => ({ type: 'client', userAgent: navigator.userAgent }))
      
      // Server-only - throws on client
      const getDbUrl = createServerOnlyFn(() => process.env.DATABASE_URL)
      
      // Client-only - throws on server
      const saveToStorage = createClientOnlyFn((key: string, value: string) => {
        localStorage.setItem(key, value)
      })
      ```
      
      For components that need browser APIs, use `ClientOnly` and `useHydrated` from `@tanstack/react-router`:
      
      ```tsx
      import { ClientOnly, useHydrated } from '@tanstack/react-router'
      
      function Analytics() {
        return (
          <ClientOnly fallback={null}>
            <GoogleAnalyticsScript />
          </ClientOnly>
        )
      }
      
      function TimeZoneDisplay() {
        const hydrated = useHydrated()
        // hydrated: false during SSR and first client render, true after hydration
        const timeZone = hydrated
          ? Intl.DateTimeFormat().resolvedOptions().timeZone
          : 'UTC'
        return <div>Your timezone: {timeZone}</div>
      }
      ```
      
      All environment functions are tree-shaken per bundle: `.client()` code is excluded from server bundles and vice versa. `createClientOnlyFn` and `createServerOnlyFn` are replaced with error-throwing stubs in the wrong environment.
      
      ## Import Protection (Experimental)
      
      Import protection prevents server-only code from leaking into client bundles and vice versa. It runs as a Vite plugin, enabled by default. In dev mode violations produce a mock (warning); in production builds they fail with an error.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/import-protection
      
      **File conventions** - use `.server.*` / `.client.*` suffixes to restrict files:
      
      ```
      src/utils/
        auth.server.ts       # Denied in client bundle
        analytics.client.ts  # Denied in server bundle
        helpers.ts           # Available in both bundles
      ```
      
      **File markers** - alternatively, use side-effect imports for files that do not follow the naming convention:
      
      ```tsx
      // src/lib/secrets.ts
      import '@tanstack/react-start/server-only'
      export const API_KEY = process.env.API_KEY
      ```
      
      **Custom deny rules** - block specific packages or directories:
      
      ```ts
      // vite.config.ts
      tanstackStart({
        importProtection: {
          client: {
            specifiers: ['@prisma/client', 'bcrypt'],
            files: ['**/db/**'],
          },
          server: {
            specifiers: ['localforage'],
          },
        },
      })
      ```
      
      ## Static Server Functions (Experimental)
      
      Static server functions execute at build time and cache results as static JSON files when using prerendering/static generation. Apply `staticFunctionMiddleware` (must be the final middleware) from `@tanstack/start-static-server-functions`:
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions'
      
      const getStaticConfig = createServerFn({ method: 'GET' })
        .middleware([staticFunctionMiddleware])
        .handler(async () => ({ siteName: 'My App', version: '1.0.0' }))
      ```
      
      At build time the result is cached as a static JSON file. On initial load the data is embedded in prerendered HTML and hydrated. On subsequent client navigations the static JSON file is fetched directly. See: https://tanstack.com/start/latest/docs/framework/react/guide/static-server-functions
      
      ## File Organization
      
      For larger applications, separate server function definitions from server-only helpers and shared schemas.
      
      ```
      src/utils/
        users.functions.ts   # Server function wrappers (createServerFn) - safe to import anywhere
        users.server.ts      # Server-only helpers (DB queries, internal logic) - server-only
        schemas.ts           # Shared validation schemas (Zod/Valibot) - client-safe
      ```
      
      ### Example Structure
      
      ```tsx
      // schemas.ts - Shared validation (safe for both environments)
      import { z } from 'zod'
      
      export const CreateUserSchema = z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
      ```
      
      ```tsx
      // users.server.ts - Server-only database logic
      import { db } from '~/db'
      
      export async function findUserById(id: string) {
        return db.query.users.findFirst({ where: eq(users.id, id) })
      }
      ```
      
      ```tsx
      // users.functions.ts - Server function wrappers (safe to import anywhere)
      import { createServerFn } from '@tanstack/react-start'
      import { zodValidator } from '@tanstack/zod-adapter'
      import { findUserById } from './users.server'
      import { CreateUserSchema } from './schemas'
      
      export const getUser = createServerFn({ method: 'GET' })
        .validator((data: { id: string }) => data)
        .handler(async ({ data }) => findUserById(data.id))
      
      export const createUser = createServerFn({ method: 'POST' })
        .validator(zodValidator(CreateUserSchema))
        .handler(async ({ data }) => db.insert(users).values(data).returning())
      ```
      
      ## Middleware Integration
      
      Server functions support composable middleware for authentication, logging, and shared logic. Use `createMiddleware({ type: 'function' })` for server-function-specific middleware.
      
      ```tsx
      import { createMiddleware, createServerFn } from '@tanstack/react-start'
      
      const authMiddleware = createMiddleware({ type: 'function' }).server(
        async ({ next }) => {
          const user = await getCurrentUser()
          if (!user) throw redirect({ to: '/login' })
          return next({ context: { user } })
        },
      )
      
      // Server function using middleware - context.user is typed
      export const getUserSettings = createServerFn()
        .middleware([authMiddleware])
        .handler(async ({ context }) => {
          return db.settings.findByUserId(context.user.id)
        })
      ```
      
      Middleware can also validate input via `validator` and run client-side logic via `.client()`. Apply middleware globally to all server functions through `src/start.ts`:
      
      ```tsx
      // src/start.ts
      import { createStart } from '@tanstack/react-start'
      import { loggingMiddleware } from './middleware'
      
      export const startInstance = createStart(() => ({
        functionMiddleware: [loggingMiddleware],
      }))
      ```
      
      See the dedicated Middleware guide for full details: https://tanstack.com/start/latest/docs/framework/react/guide/middleware
      
      ## Best Practices
      
      1. **Always use server functions for sensitive operations, and authorize inside them.** Route loaders are isomorphic (run on both server and client). Never access secrets, database connections, or server-only APIs directly in loaders - wrap them in `createServerFn()`. Because a server function is an endpoint reachable independently of any route, enforce auth in the handler or its middleware - `beforeLoad` route guards are UX, not the data boundary.
      
      2. **Validate all input with schemas.** Server functions cross a network boundary. Use Zod, Valibot, or ArkType to validate data at runtime, not just TypeScript types.
      
      3. **Use POST for mutations, GET for reads.** GET server functions can be cached and preloaded by the router. POST is appropriate for any operation that modifies state.
      
      4. **Separate concerns with file conventions.** Use `.functions.ts` for `createServerFn` wrappers, `.server.ts` for database queries and internal helpers, and plain `.ts` for shared types and schemas.
      
      5. **Prefer async generators for streaming.** They produce cleaner code on both server and client compared to manual `ReadableStream` construction. Use `for await...of` on the client side.
      
      6. **Compose with middleware for cross-cutting concerns.** Authentication, authorization, logging, and input validation are ideal candidates for middleware rather than repeated logic inside each handler.
      
      7. **Always use static imports for server functions.** The build process cannot replace dynamic imports with RPC stubs, which leads to bundler issues.
      
      8. **Use import protection file conventions.** Name server-only files with `.server.ts` and client-only files with `.client.ts` to get automatic build-time protection against cross-environment leaks.
      
    • server-routes.md 17.5 KB
      # Server Routes (API Routes)
      
      Server routes are server-side HTTP endpoints defined alongside your application routes using the same file-based routing conventions as TanStack Router. They handle raw HTTP requests, form submissions, webhooks, and authentication callbacks.
      
      Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes
      
      ## Defining Server Routes
      
      Add a `server` property to `createFileRoute()` with a `handlers` object mapping HTTP methods to handler functions. Each handler must return a standard Web `Response`.
      
      ```ts
      // src/routes/api/health.ts
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/api/health')({
        server: {
          handlers: {
            GET: async ({ request }) => {
              return Response.json({ status: 'ok', timestamp: Date.now() })
            },
          },
        },
      })
      ```
      
      Supported HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`.
      
      ## Co-location with App Routes
      
      Server routes and UI routes can share the same file. The `server` property defines API handlers while `component` and other options define the page.
      
      ```tsx
      // src/routes/contact.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/contact')({
        server: {
          handlers: {
            POST: async ({ request }) => {
              const body = await request.json()
              await sendContactEmail(body.email, body.message)
              return Response.json({ success: true })
            },
          },
        },
        component: () => {
          const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
            e.preventDefault()
            const form = new FormData(e.currentTarget)
            await fetch('/contact', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ email: form.get('email'), message: form.get('message') }),
            })
          }
          return (
            <form onSubmit={handleSubmit}>
              <input name="email" type="email" required />
              <textarea name="message" required />
              <button type="submit">Send</button>
            </form>
          )
        },
      })
      ```
      
      ## File Routing Conventions
      
      Server routes follow the same file-based routing as TanStack Router. Files with a `server` property in `createFileRoute` become API endpoints.
      
      | File path | API endpoint |
      |-----------|-------------|
      | `routes/api/users.ts` | `/api/users` |
      | `routes/api/users/$id.ts` | `/api/users/$id` |
      | `routes/api/users.$id.posts.ts` | `/api/users/$id/posts` |
      | `routes/api/file/$.ts` | `/api/file/$` (wildcard) |
      | `routes/sitemap[.]xml.ts` | `/sitemap.xml` (escaped dot) |
      
      Each route path must resolve to a single handler file. Flat file names and nested directories can be mixed freely - `routes/api/users.$id.ts` and `routes/api/users/$id.ts` produce the same endpoint.
      
      ## Handler Context
      
      Every handler receives an object with three properties:
      
      - **`request`** - Standard Web `Request` object. Use `request.json()`, `request.text()`, `request.formData()`, `request.headers`, `request.url`.
      - **`params`** - Typed path parameters. For `/api/users/$id/posts/$postId`, params is `{ id: string, postId: string }`.
      - **`context`** - Data passed from middleware via `next({ context: { ... } })`. Empty by default.
      
      ```ts
      export const Route = createFileRoute('/api/users/$id')({
        server: {
          handlers: {
            GET: async ({ request, params, context }) => {
              return Response.json({ userId: params.id, url: request.url })
            },
          },
        },
      })
      ```
      
      ## Handler Definition
      
      ### Simple Handlers Object
      
      Pass handler functions directly for straightforward use cases:
      
      ```ts
      export const Route = createFileRoute('/api/posts')({
        server: {
          handlers: {
            GET: async ({ request }) => {
              const url = new URL(request.url)
              const page = Number(url.searchParams.get('page') || '1')
              return Response.json(await fetchPosts(page))
            },
            POST: async ({ request }) => {
              const body = await request.json()
              return Response.json(await createPost(body), { status: 201 })
            },
          },
        },
      })
      ```
      
      ### createHandlers for Per-Handler Middleware
      
      Use `createHandlers` to attach middleware to individual HTTP methods:
      
      ```ts
      export const Route = createFileRoute('/api/admin/settings')({
        server: {
          handlers: ({ createHandlers }) =>
            createHandlers({
              GET: {
                middleware: [adminMiddleware],
                handler: async ({ context }) => Response.json(context.settings),
              },
              PUT: {
                middleware: [adminMiddleware, validationMiddleware],
                handler: async ({ request }) => {
                  const body = await request.json()
                  return Response.json(await updateSettings(body))
                },
              },
            }),
        },
      })
      ```
      
      ### Route-Level and Combined Middleware
      
      Apply middleware to all handlers with the top-level `middleware` property. Route-level middleware runs first, then handler-specific middleware:
      
      ```ts
      export const Route = createFileRoute('/api/documents')({
        server: {
          middleware: [authMiddleware], // Runs for every handler
          handlers: ({ createHandlers }) =>
            createHandlers({
              GET: async ({ context }) => {
                return Response.json(await getDocuments(context.user.id))
              },
              POST: {
                middleware: [rateLimitMiddleware], // Runs after authMiddleware, POST only
                handler: async ({ request, context }) => {
                  const body = await request.json()
                  return Response.json(await createDocument(context.user.id, body), { status: 201 })
                },
              },
            }),
        },
      })
      ```
      
      ## Dynamic Path Params
      
      ### Single and Multiple Parameters
      
      ```ts
      // src/routes/api/users/$userId/posts/$postId.ts
      export const Route = createFileRoute('/api/users/$userId/posts/$postId')({
        server: {
          handlers: {
            GET: async ({ params }) => {
              const { userId, postId } = params
              const post = await findPost(userId, postId)
              if (!post) return Response.json({ error: 'Not found' }, { status: 404 })
              return Response.json(post)
            },
          },
        },
      })
      ```
      
      ### Wildcard (Splat) Parameter
      
      A trailing `$` with no name captures the remaining path as `_splat`:
      
      ```ts
      // src/routes/api/files/$.ts
      export const Route = createFileRoute('/api/files/$')({
        server: {
          handlers: {
            GET: async ({ params }) => {
              const filePath = params._splat // e.g. "documents/report.pdf"
              const file = await readFile(filePath)
              return new Response(file, {
                headers: { 'Content-Type': getMimeType(filePath) },
              })
            },
          },
        },
      })
      // GET /api/files/documents/report.pdf -> _splat = "documents/report.pdf"
      ```
      
      ## Pathless Layout Routes
      
      Pathless layout routes (prefixed with `_`) group server routes under shared middleware without adding a URL segment:
      
      ```
      src/routes/api/
        _authenticated.ts          # Middleware-only, no path segment
        _authenticated/
          users.ts                 # /api/users (requires auth)
          posts.ts                 # /api/posts (requires auth)
        public/
          health.ts                # /api/public/health (no auth)
      ```
      
      ```ts
      // src/routes/api/_authenticated.ts
      import { createFileRoute } from '@tanstack/react-router'
      import { authMiddleware } from '~/middleware/auth'
      
      export const Route = createFileRoute('/api/_authenticated')({
        server: { middleware: [authMiddleware] },
      })
      ```
      
      All routes inside `_authenticated/` inherit the auth middleware. Break-out routes can escape parent middleware.
      
      ## Response Handling
      
      Handlers return standard Web `Response` objects:
      
      ```ts
      // JSON (preferred) - sets Content-Type automatically
      return Response.json({ message: 'Hello' })
      return Response.json(created, { status: 201 })
      return Response.json({ error: 'Not found' }, { status: 404 })
      
      // Plain text
      return new Response('Hello, World!', { headers: { 'Content-Type': 'text/plain' } })
      
      // Custom headers
      return new Response(JSON.stringify(data), {
        headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
      })
      
      // Redirect
      return new Response(null, { status: 302, headers: { Location: '/new-location' } })
      ```
      
      ### Streaming Response
      
      ```ts
      export const Route = createFileRoute('/api/events')({
        server: {
          handlers: {
            GET: async () => {
              const stream = new ReadableStream({
                async start(controller) {
                  const encoder = new TextEncoder()
                  for (let i = 0; i < 5; i++) {
                    await new Promise((resolve) => setTimeout(resolve, 1000))
                    controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`))
                  }
                  controller.close()
                },
              })
              return new Response(stream, {
                headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
              })
            },
          },
        },
      })
      ```
      
      ## Database Integration
      
      Server routes run entirely on the server, giving direct access to databases and environment variables.
      
      ### With Neon (Serverless PostgreSQL)
      
      ```ts
      import { createFileRoute } from '@tanstack/react-router'
      import { neon } from '@neondatabase/serverless'
      
      const sql = neon(process.env.DATABASE_URL!)
      
      export const Route = createFileRoute('/api/products')({
        server: {
          handlers: {
            GET: async () => {
              const products = await sql`SELECT id, name, price FROM products WHERE active = true LIMIT 50`
              return Response.json(products)
            },
            POST: async ({ request }) => {
              const { name, price } = await request.json()
              const [product] = await sql`INSERT INTO products (name, price) VALUES (${name}, ${price}) RETURNING *`
              return Response.json(product, { status: 201 })
            },
          },
        },
      })
      ```
      
      ### With Prisma / Drizzle
      
      The same pattern works with any ORM - import your client and call it from handlers:
      
      ```ts
      // Prisma
      const users = await prisma.user.findMany({ skip: (page - 1) * 20, take: 20 })
      
      // Drizzle
      const allPosts = await db.select().from(posts).orderBy(desc(posts.createdAt)).limit(25)
      ```
      
      ## Authentication in Server Routes
      
      ### Auth Middleware
      
      ```ts
      // src/middleware/auth.ts
      import { createMiddleware } from '@tanstack/react-start'
      import { useAppSession } from '~/utils/session'
      
      export const authMiddleware = createMiddleware().server(async ({ next }) => {
        const session = await useAppSession()
        if (!session.data.userId) {
          return Response.json({ error: 'Unauthorized' }, { status: 401 })
        }
        const user = await getUserById(session.data.userId)
        if (!user) return Response.json({ error: 'User not found' }, { status: 401 })
        return next({ context: { user } })
      })
      ```
      
      ### Role-Based Authorization
      
      ```ts
      // src/middleware/roles.ts
      import { createMiddleware } from '@tanstack/react-start'
      
      export function requireRole(role: string) {
        return createMiddleware().server(async ({ next, context }) => {
          if (context.user?.role !== role) {
            return Response.json({ error: 'Forbidden' }, { status: 403 })
          }
          return next()
        })
      }
      ```
      
      ```ts
      export const Route = createFileRoute('/api/admin/users')({
        server: {
          middleware: [authMiddleware],
          handlers: ({ createHandlers }) =>
            createHandlers({
              GET: {
                middleware: [requireRole('admin')],
                handler: async () => Response.json(await getAllUsers()),
              },
            }),
        },
      })
      ```
      
      ## Complete CRUD Example
      
      ```ts
      // src/routes/api/tasks.ts
      import { createFileRoute } from '@tanstack/react-router'
      import { authMiddleware } from '~/middleware/auth'
      import { prisma } from '~/lib/prisma'
      
      export const Route = createFileRoute('/api/tasks')({
        server: {
          middleware: [authMiddleware],
          handlers: {
            GET: async ({ request, context }) => {
              const url = new URL(request.url)
              const status = url.searchParams.get('status')
              const where: Record<string, unknown> = { userId: context.user.id }
              if (status) where.status = status
              const tasks = await prisma.task.findMany({ where, orderBy: { createdAt: 'desc' } })
              return Response.json(tasks)
            },
            POST: async ({ request, context }) => {
              const body = await request.json()
              if (!body.title || typeof body.title !== 'string') {
                return Response.json({ error: 'Title is required' }, { status: 400 })
              }
              const task = await prisma.task.create({
                data: { title: body.title, description: body.description || null, status: 'pending', userId: context.user.id },
              })
              return Response.json(task, { status: 201 })
            },
          },
        },
      })
      ```
      
      ```ts
      // src/routes/api/tasks/$taskId.ts
      import { createFileRoute } from '@tanstack/react-router'
      import { authMiddleware } from '~/middleware/auth'
      import { prisma } from '~/lib/prisma'
      
      export const Route = createFileRoute('/api/tasks/$taskId')({
        server: {
          middleware: [authMiddleware],
          handlers: {
            GET: async ({ params, context }) => {
              const task = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } })
              if (!task) return Response.json({ error: 'Not found' }, { status: 404 })
              return Response.json(task)
            },
            PUT: async ({ request, params, context }) => {
              const existing = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } })
              if (!existing) return Response.json({ error: 'Not found' }, { status: 404 })
              const body = await request.json()
              const updated = await prisma.task.update({
                where: { id: params.taskId },
                data: { title: body.title ?? existing.title, description: body.description ?? existing.description, status: body.status ?? existing.status },
              })
              return Response.json(updated)
            },
            DELETE: async ({ params, context }) => {
              const existing = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } })
              if (!existing) return Response.json({ error: 'Not found' }, { status: 404 })
              await prisma.task.delete({ where: { id: params.taskId } })
              return Response.json({ deleted: true })
            },
          },
        },
      })
      ```
      
      ## Head Management
      
      Routes define a `head()` function for SEO metadata. Use `HeadContent` and `Scripts` in the root layout to render tags into the document.
      
      ### Root Layout Setup
      
      ```tsx
      // src/routes/__root.tsx
      import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'
      
      export const Route = createRootRoute({
        head: () => ({
          meta: [{ charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
          links: [{ rel: 'icon', href: '/favicon.ico' }],
        }),
        component: () => (
          <html>
            <head><HeadContent /></head>
            <body><Outlet /><Scripts /></body>
          </html>
        ),
      })
      ```
      
      ### Dynamic Meta Tags
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => ({ post: await fetchPost(params.postId) }),
        head: ({ loaderData }) => ({
          meta: [
            { title: loaderData.post.title },
            { name: 'description', content: loaderData.post.excerpt },
            { property: 'og:title', content: loaderData.post.title },
            { property: 'og:image', content: loaderData.post.coverImage },
            { name: 'twitter:card', content: 'summary_large_image' },
          ],
          scripts: [{
            type: 'application/ld+json',
            children: JSON.stringify({
              '@context': 'https://schema.org', '@type': 'Article',
              headline: loaderData.post.title, image: loaderData.post.coverImage,
              datePublished: loaderData.post.publishedAt,
            }),
          }],
        }),
        component: PostPage,
      })
      ```
      
      ## Global Middleware via start.ts
      
      Register middleware that runs on every server request (SSR, server functions, and server routes):
      
      ```ts
      // src/start.ts
      import { createStart, createMiddleware } from '@tanstack/react-start'
      
      const loggingMiddleware = createMiddleware().server(async ({ next, request }) => {
        const start = Date.now()
        const result = await next()
        console.log(`${request.method} ${request.url} - ${Date.now() - start}ms`)
        return result
      })
      
      export const startInstance = createStart(() => ({
        requestMiddleware: [loggingMiddleware],
      }))
      ```
      
      ## Best Practices
      
      1. **Use `Response.json()` over manual serialization** - Sets Content-Type automatically and avoids forgetting to stringify.
      
      2. **Validate request bodies before processing** - Check required fields and types before database calls. Return 400 with descriptive error messages.
      
      3. **Scope server routes under `/api` by convention** - Placing API routes under `src/routes/api/` makes the boundary between UI and API endpoints clear.
      
      4. **Use middleware for cross-cutting concerns** - Extract auth, logging, rate limiting into reusable middleware rather than duplicating in each handler.
      
      5. **Return appropriate HTTP status codes** - 201 for creation, 404 for not found, 401 for unauthenticated, 403 for forbidden, 400 for bad input.
      
      6. **Prefer pathless layout routes for auth boundaries** - Group protected endpoints under a pathless layout with auth middleware instead of repeating it per route.
      
      7. **Keep handlers thin** - Move business logic to service modules. Handlers should parse input, call services, and format responses.
      
      ## References
      
      - Server Routes: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes
      - Middleware: https://tanstack.com/start/latest/docs/framework/react/guide/middleware
      - SEO: https://tanstack.com/start/latest/docs/framework/react/guide/seo
      - Databases: https://tanstack.com/start/latest/docs/framework/react/guide/databases
      - Authentication: https://tanstack.com/start/latest/docs/framework/react/guide/authentication
      - File-based routing: https://tanstack.com/router/latest/docs/framework/react/routing/file-based-routing
      - MDN Request API: https://developer.mozilla.org/en-US/docs/Web/API/Request
      - MDN Response API: https://developer.mozilla.org/en-US/docs/Web/API/Response
      
    • ssr-modes.md 32.5 KB
      # SSR Modes and Rendering Strategies
      
      TanStack Start provides granular control over server-side rendering at the application, route, and even request level. This reference covers every rendering mode, deployment target, and caching strategy.
      
      ## Full-Document SSR (Default)
      
      SSR is enabled by default. On the initial request, TanStack Start renders all matched route components on the server, sends complete HTML to the client, and hydrates it into an interactive application.
      
      The default SSR flow:
      
      1. Server receives a request and matches routes
      2. `beforeLoad` and `loader` execute on the server for all matched routes
      3. Components render to HTML on the server
      4. HTML is sent to the client along with serialized loader data
      5. Client hydrates the markup into a fully interactive React application
      
      ```tsx
      // src/routes/posts/$postId.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/posts/$postId')({
        // ssr defaults to true - no configuration needed
        beforeLoad: () => {
          console.log('Runs on the server during initial request')
          console.log('Runs on the client for subsequent navigation')
        },
        loader: async ({ params }) => {
          const post = await fetchPost(params.postId)
          return { post }
        },
        component: PostPage,
      })
      
      function PostPage() {
        const { post } = Route.useLoaderData()
        return (
          <article>
            <h1>{post.title}</h1>
            <p>{post.content}</p>
          </article>
        )
      }
      ```
      
      After hydration, subsequent navigations run `beforeLoad` and `loader` on the client. Route loaders are isomorphic - they execute on the server during SSR and on the client during client-side navigation.
      
      ## Streaming SSR
      
      TanStack Start uses streaming by default for SSR responses. The server streams HTML progressively to the client as components render. Combined with React Suspense boundaries, this enables progressive page loading where above-the-fold content arrives first while deferred data loads asynchronously.
      
      Streaming works with server functions that return `ReadableStream` or use async generators:
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      
      type Message = {
        content: string
      }
      
      // Using ReadableStream
      const streamMessages = createServerFn().handler(async () => {
        const messages: Message[] = await getMessages()
      
        const stream = new ReadableStream<Message>({
          async start(controller) {
            for (const message of messages) {
              controller.enqueue(message)
            }
            controller.close()
          },
        })
      
        return stream
      })
      
      // Using async generators (cleaner approach)
      const streamMessagesGenerator = createServerFn().handler(async function* () {
        const messages: Message[] = await getMessages()
        for (const msg of messages) {
          yield msg
        }
      })
      ```
      
      Client consumption with typed streaming:
      
      ```tsx
      function MessageFeed() {
        const [messages, setMessages] = useState('')
      
        const loadMessages = useCallback(async () => {
          for await (const msg of await streamMessagesGenerator()) {
            // msg is typed as Message
            setMessages((prev) => prev + msg.content)
          }
        }, [])
      
        return (
          <div>
            <button onClick={loadMessages}>Load Messages</button>
            <pre>{messages}</pre>
          </div>
        )
      }
      ```
      
      The custom server handler controls streaming behavior through `src/server.ts`:
      
      ```tsx
      // src/server.ts
      import {
        createStartHandler,
        defaultStreamHandler,
        defineHandlerCallback,
      } from '@tanstack/react-start/server'
      import { createServerEntry } from '@tanstack/react-start/server-entry'
      
      const customHandler = defineHandlerCallback((ctx) => {
        // Add custom logic before streaming
        return defaultStreamHandler(ctx)
      })
      
      const fetch = createStartHandler(customHandler)
      
      export default createServerEntry({
        fetch,
      })
      ```
      
      ## Per-Route SSR Control (Selective SSR)
      
      The `ssr` property on a route controls server-side behavior during the initial request. There are three values and a functional form.
      
      ### ssr: true (Default)
      
      Server runs `beforeLoad` and `loader`, renders the component, and sends full HTML.
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        ssr: true,
        beforeLoad: () => {
          console.log('Executes on the server during the initial request')
          console.log('Executes on the client for subsequent navigation')
        },
        loader: () => {
          console.log('Executes on the server during the initial request')
          console.log('Executes on the client for subsequent navigation')
        },
        component: () => <div>This component is rendered on the server</div>,
      })
      ```
      
      ### ssr: false (Client-Only Rendering)
      
      Disables server-side execution of `beforeLoad`, `loader`, and component rendering entirely. Everything runs on the client during hydration.
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        ssr: false,
        beforeLoad: () => {
          // Uses browser-only API safely
          const token = localStorage.getItem('auth_token')
          if (!token) throw redirect({ to: '/login' })
        },
        loader: () => {
          console.log('Executes on the client during hydration')
        },
        component: () => <div>This component is rendered on the client</div>,
      })
      ```
      
      Use `ssr: false` when:
      
      - `beforeLoad` or `loader` depends on browser-only APIs (e.g., `localStorage`, `canvas`)
      - The route component depends on browser-only APIs
      - SEO is not important for this route
      
      **Hydration mismatches from client-only providers.** Providers whose initial render differs between server and client (wallet/web3 hooks, theme from `localStorage`, anything reading `window`) throw hydration-mismatch errors under SSR. Set `ssr: false` on the routes that need them, and/or wrap the client-only subtree in `<ClientOnly fallback={null}>` (from `@tanstack/react-router`) so it renders only after hydration.
      
      ### ssr: 'data-only' (Server Data, Client Rendering)
      
      A hybrid option. The server runs `beforeLoad` and `loader` and sends the data to the client, but the component renders only on the client.
      
      ```tsx
      export const Route = createFileRoute('/charts')({
        ssr: 'data-only',
        beforeLoad: () => {
          console.log('Executes on the server during the initial request')
        },
        loader: async () => {
          // Data fetched on server, but component using canvas renders on client
          return await fetchChartData()
        },
        component: () => {
          const data = Route.useLoaderData()
          // Canvas-based chart that requires browser APIs
          return <CanvasChart data={data} />
        },
      })
      ```
      
      ### Functional Form (Runtime Decisions)
      
      For dynamic SSR decisions based on route params or search params:
      
      ```tsx
      import { z } from 'zod'
      
      export const Route = createFileRoute('/docs/$docType/$docId')({
        validateSearch: z.object({ details: z.boolean().optional() }),
        ssr: ({ params, search }) => {
          // Disable SSR for sheet-type documents
          if (params.status === 'success' && params.value.docType === 'sheet') {
            return false
          }
          // Use data-only for detail views
          if (search.status === 'success' && search.value.details) {
            return 'data-only'
          }
          // Default: full SSR (return undefined or true)
        },
        loader: async ({ params }) => {
          return await fetchDoc(params.docId)
        },
        component: DocViewer,
      })
      ```
      
      The `ssr` function runs only on the server during the initial request and is stripped from the client bundle. The `search` and `params` arguments are passed as discriminated unions after validation:
      
      ```tsx
      params:
        | { status: 'success'; value: ResolvedParams }
        | { status: 'error'; error: unknown }
      search:
        | { status: 'success'; value: ResolvedSearch }
        | { status: 'error'; error: unknown }
      ```
      
      ### Changing the Default SSR Mode
      
      You can change the default for all routes via `createStart`:
      
      ```tsx
      // src/start.ts
      import { createStart } from '@tanstack/react-start'
      
      export const startInstance = createStart(() => ({
        defaultSsr: false, // All routes default to client-only
      }))
      ```
      
      ## Inheritance Rules
      
      Child routes inherit the SSR configuration of their parent, but children can only be MORE restrictive, never less. The restrictiveness order is: `true` > `'data-only'` > `false`.
      
      ```
      Allowed transitions (parent -> child):
        true       -> true, 'data-only', false
        'data-only' -> 'data-only', false
        false      -> false
      ```
      
      Example hierarchy:
      
      ```
      root { ssr: undefined }          -> resolves to true (default)
        posts { ssr: 'data-only' }    -> data-only (more restrictive than parent)
          $postId { ssr: true }       -> stays data-only (cannot become LESS restrictive)
            details { ssr: false }    -> false (more restrictive than data-only)
      ```
      
      Another example:
      
      ```
      root { ssr: undefined }          -> true
        posts { ssr: false }           -> false
          $postId { ssr: true }        -> stays false (cannot override parent restriction)
      ```
      
      ## Fallback Rendering
      
      When the server encounters the first route with `ssr: false` or `ssr: 'data-only'`, it renders a fallback instead of the route component.
      
      ### pendingComponent Fallback
      
      The route's `pendingComponent` is rendered as the fallback. If not configured, `defaultPendingComponent` is used. If neither exists, no fallback is rendered.
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        ssr: false,
        pendingComponent: () => (
          <div className="flex items-center justify-center h-full">
            <Spinner />
            <span>Loading dashboard...</span>
          </div>
        ),
        loader: async () => await fetchDashboardData(),
        component: Dashboard,
      })
      ```
      
      On the client during hydration, this fallback is displayed for at least `minPendingMs` (or `defaultPendingMinMs` if not configured on the route), even if there is no `beforeLoad` or `loader`.
      
      ### shellComponent for Root Route
      
      When disabling SSR on the root route, the `<html>` shell still needs to be rendered on the server. Use `shellComponent` for this:
      
      ```tsx
      import * as React from 'react'
      import {
        HeadContent,
        Outlet,
        Scripts,
        createRootRoute,
      } from '@tanstack/react-router'
      
      export const Route = createRootRoute({
        shellComponent: RootShell,
        component: RootComponent,
        errorComponent: () => <div>Error</div>,
        notFoundComponent: () => <div>Not found</div>,
        ssr: false,
      })
      
      function RootShell({ children }: { children: React.ReactNode }) {
        return (
          <html>
            <head>
              <HeadContent />
            </head>
            <body>
              {children}
              <Scripts />
            </body>
          </html>
        )
      }
      
      function RootComponent() {
        return (
          <div>
            <h1>This component will be rendered on the client</h1>
            <Outlet />
          </div>
        )
      }
      ```
      
      The `shellComponent` is always SSR-rendered and wraps around the root `component`, `errorComponent`, or `notFoundComponent`.
      
      ## SPA Mode
      
      SPA mode completely disables server-side rendering for all routes. The build produces a static HTML shell served from a CDN, and JavaScript takes over entirely on the client.
      
      ### Enabling SPA Mode
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [
          tanstackStart({
            spa: {
              enabled: true,
            },
          }),
          viteReact(),
        ],
      })
      ```
      
      ### How SPA Mode Works
      
      1. At build time, the root route is prerendered with matched routes replaced by the pending fallback component
      2. The resulting HTML is stored as `/_shell.html` (configurable)
      3. Default rewrites redirect all 404 requests to the SPA shell
      4. On the client, JavaScript hydrates and takes over routing
      
      Server functions and server routes still work - they are served as API endpoints. SPA mode only disables SSR of the HTML document.
      
      ### Benefits and Tradeoffs
      
      Benefits:
      - Easier to deploy - a CDN serving static assets is all you need
      - Cheaper to host - CDNs are inexpensive compared to Lambda functions or persistent servers
      - Simpler - no hydration mismatches to debug
      
      Caveats:
      - Slower time to full content - all JS must download and execute before rendering
      - Less SEO friendly - crawlers may not execute JavaScript or may time out
      
      ### SPA Redirect Configuration
      
      Deploy targets need redirects so that all URLs serve the shell. Example for Netlify `_redirects`:
      
      ```
      # Allow server functions to pass through
      /_serverFn/* /_serverFn/:splat 200
      
      # Allow API server routes to pass through
      /api/* /api/:splat 200
      
      # Rewrite everything else to the SPA shell
      /* /_shell.html 200
      ```
      
      ### SPA Shell Customization
      
      Use `router.isShell()` to detect shell rendering:
      
      ```tsx
      // src/routes/__root.tsx
      function RootComponent() {
        const isShell = useRouter().isShell()
      
        if (isShell) {
          return <AppShellSkeleton />
        }
      
        return (
          <div>
            <Navigation />
            <Outlet />
          </div>
        )
      }
      ```
      
      After hydration, `isShell()` returns `false` once the router navigates to the actual route.
      
      ### SPA Shell with Dynamic Data
      
      The root route's `loader` runs during prerendering, so its data is baked into the shell:
      
      ```tsx
      export const Route = createRootRoute({
        loader: async () => {
          return { appName: 'My Application', version: '2.0' }
        },
        component: Root,
      })
      
      function Root() {
        const { appName, version } = Route.useLoaderData()
        return (
          <html>
            <head>
              <title>{appName}</title>
            </head>
            <body>
              <Outlet />
            </body>
          </html>
        )
      }
      ```
      
      ### SPA Prerender Options
      
      Configure prerendering behavior for the SPA shell:
      
      ```tsx
      // vite.config.ts
      export default defineConfig({
        plugins: [
          tanstackStart({
            spa: {
              enabled: true,
              maskPath: '/', // Pathname used to generate the shell (default: '/')
              prerender: {
                outputPath: '/_shell.html', // Default
                crawlLinks: false,          // Default: false for SPA
                retryCount: 0,              // Default: 0 for SPA
              },
            },
          }),
          viteReact(),
        ],
      })
      ```
      
      ## Static Prerendering (SSG)
      
      Static prerendering generates HTML files at build time. Pages are served as static files without on-the-fly server rendering.
      
      ### Basic Configuration
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [
          tanstackStart({
            prerender: {
              enabled: true,
              crawlLinks: true,
            },
          }),
          viteReact(),
        ],
      })
      ```
      
      ### Full Prerender Options
      
      ```tsx
      // vite.config.ts
      export default defineConfig({
        plugins: [
          tanstackStart({
            prerender: {
              // Enable prerendering
              enabled: true,
      
              // Generate /page/index.html instead of /page.html
              autoSubfolderIndex: true,
      
              // Auto-discover static routes from the route tree
              // When disabled, only root and explicitly listed pages are prerendered
              autoStaticPathsDiscovery: true,
      
              // Number of concurrent prerender jobs
              concurrency: 14,
      
              // Extract links from rendered HTML and prerender those pages too
              crawlLinks: true,
      
              // Filter which pages to prerender
              filter: ({ path }) => !path.startsWith('/admin'),
      
              // Retry count for failed prerender jobs
              retryCount: 2,
      
              // Delay between retries in milliseconds
              retryDelay: 1000,
      
              // Maximum redirects to follow during prerendering
              maxRedirects: 5,
      
              // Abort the build if any prerender fails
              failOnError: true,
      
              // Callback on successful render
              onSuccess: ({ page }) => {
                console.log(`Prerendered: ${page.path}`)
              },
            },
      
            // Explicit page configuration (merged with auto-discovered routes)
            pages: [
              {
                path: '/landing',
                prerender: { enabled: true, outputPath: '/landing/index.html' },
              },
            ],
          }),
          viteReact(),
        ],
      })
      ```
      
      ### Automatic Static Route Discovery
      
      Static routes are discovered automatically when `autoStaticPathsDiscovery` is enabled (the default). Routes excluded from auto-discovery:
      
      - Routes with path parameters (e.g., `/users/$userId`) - they require specific parameter values
      - Layout routes (prefixed with `_`) - they do not render standalone pages
      - Routes without components (e.g., API routes)
      
      Dynamic routes can still be prerendered if linked from other pages when `crawlLinks` is enabled.
      
      ### Link Crawling
      
      When `crawlLinks: true`, TanStack Start extracts links from each prerendered page and prerenders those linked pages too. For example, if `/` contains a `<Link to="/posts">`, then `/posts` is also prerendered.
      
      ## Incremental Static Regeneration (ISR)
      
      TanStack Start implements ISR through standard HTTP `Cache-Control` headers rather than proprietary APIs. This works with any CDN that respects cache headers.
      
      ### How ISR Works
      
      1. Pages are prerendered at build time (static prerendering)
      2. CDN caches the HTML based on `Cache-Control` headers
      3. After cache expires, the next request triggers server-side regeneration
      4. `stale-while-revalidate` serves stale content while fresh content generates in the background
      
      ### Route-Level Cache Headers
      
      ```tsx
      // src/routes/blog/posts/$postId.tsx
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/blog/posts/$postId')({
        loader: async ({ params }) => {
          const post = await fetchPost(params.postId)
          return { post }
        },
        headers: () => ({
          // Cache at CDN for 1 hour, serve stale for up to 1 day while revalidating
          'Cache-Control':
            'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
        }),
        component: BlogPost,
      })
      
      function BlogPost() {
        const { post } = Route.useLoaderData()
        return (
          <article>
            <h1>{post.title}</h1>
            <div>{post.content}</div>
          </article>
        )
      }
      ```
      
      ### Cache-Control Directives
      
      | Directive | Purpose |
      |-----------|---------|
      | `public` | Response can be cached by any cache (CDN, browser) |
      | `private` | Only the browser can cache (not CDN) |
      | `max-age=N` | Content is fresh for N seconds in the browser |
      | `s-maxage=N` | Overrides max-age for shared/CDN caches |
      | `stale-while-revalidate=N` | Serve stale content for up to N seconds while fetching fresh content in the background |
      | `immutable` | Content never changes (for hashed assets) |
      
      ### ISR with Prerendering
      
      Combine build-time prerendering with runtime ISR:
      
      ```tsx
      // vite.config.ts
      export default defineConfig({
        plugins: [
          tanstackStart({
            prerender: {
              enabled: true,
              crawlLinks: true,
            },
          }),
          viteReact(),
        ],
      })
      ```
      
      ```tsx
      // src/routes/products/$productId.tsx
      export const Route = createFileRoute('/products/$productId')({
        loader: async ({ params }) => {
          return await fetchProduct(params.productId)
        },
        // CDN caching (ISR)
        headers: () => ({
          'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
        }),
        // Client-side caching (TanStack Router)
        staleTime: 30_000,
        gcTime: 5 * 60_000,
        component: ProductPage,
      })
      ```
      
      This creates a multi-tier caching strategy:
      
      1. CDN edge: 5-minute cache, stale-while-revalidate for 1 hour
      2. Client memory: 30 seconds fresh, 5 minutes in cache
      
      ### On-Demand Revalidation (CDN Purge)
      
      For immediate invalidation when content changes, purge the CDN cache via API:
      
      ```tsx
      // src/routes/api/revalidate.ts
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/api/revalidate')({
        server: {
          handlers: {
            POST: async ({ request }) => {
              const { path, secret } = await request.json()
      
              if (secret !== process.env.REVALIDATE_SECRET) {
                return Response.json({ error: 'Invalid token' }, { status: 401 })
              }
      
              // Purge via Cloudflare API (adapt for your CDN)
              await fetch(
                `https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`,
                {
                  method: 'POST',
                  headers: {
                    Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
                    'Content-Type': 'application/json',
                  },
                  body: JSON.stringify({
                    files: [`https://yoursite.com${path}`],
                  }),
                },
              )
      
              return Response.json({ revalidated: true })
            },
          },
        },
      })
      ```
      
      ### CDN-Specific Cache Headers
      
      ```tsx
      // Cloudflare Workers
      export const Route = createFileRoute('/products/$id')({
        headers: () => ({
          'Cache-Control': 'public, max-age=3600',
          'CDN-Cache-Control': 'max-age=7200', // Cloudflare-specific override
        }),
      })
      ```
      
      Netlify supports `_headers` files:
      
      ```
      # public/_headers
      /blog/*
        Cache-Control: public, max-age=3600, stale-while-revalidate=86400
      
      /api/*
        Cache-Control: public, max-age=300
      ```
      
      ### Common ISR Patterns
      
      Blog posts (infrequent updates):
      
      ```tsx
      export const Route = createFileRoute('/blog/$slug')({
        loader: async ({ params }) => fetchPost(params.slug),
        headers: () => ({
          'Cache-Control': 'public, max-age=3600, stale-while-revalidate=604800',
        }),
        staleTime: 5 * 60_000,
      })
      ```
      
      E-commerce product pages (inventory changes):
      
      ```tsx
      export const Route = createFileRoute('/products/$id')({
        loader: async ({ params }) => fetchProduct(params.id),
        headers: () => ({
          'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
        }),
        staleTime: 30_000,
      })
      ```
      
      User-specific pages (no CDN caching):
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        loader: async () => fetchUserData(),
        headers: () => ({
          'Cache-Control': 'private, max-age=60',
        }),
        staleTime: 30_000,
      })
      ```
      
      ## Deployment Targets
      
      ### Cloudflare Workers (Official Partner)
      
      Install dependencies:
      
      ```bash
      pnpm add -D @cloudflare/vite-plugin wrangler
      ```
      
      Vite configuration:
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import { cloudflare } from '@cloudflare/vite-plugin'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [
          cloudflare({ viteEnvironment: { name: 'ssr' } }),
          tanstackStart(),
          viteReact(),
        ],
      })
      ```
      
      Wrangler config (`wrangler.jsonc`):
      
      ```json
      {
        "$schema": "node_modules/wrangler/config-schema.json",
        "name": "tanstack-start-app",
        "compatibility_date": "2025-09-02",
        "compatibility_flags": ["nodejs_compat"],
        "main": "@tanstack/react-start/server-entry"
      }
      ```
      
      Package scripts:
      
      ```json
      {
        "scripts": {
          "dev": "vite dev",
          "build": "vite build && tsc --noEmit",
          "preview": "vite preview",
          "deploy": "npm run build && wrangler deploy"
        }
      }
      ```
      
      ### Netlify (Official Partner)
      
      Install the plugin:
      
      ```bash
      pnpm add -D @netlify/vite-plugin-tanstack-start
      ```
      
      Vite configuration:
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import netlify from '@netlify/vite-plugin-tanstack-start'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [
          tanstackStart(),
          netlify(),
          viteReact(),
        ],
      })
      ```
      
      Deploy:
      
      ```bash
      npx netlify deploy
      ```
      
      Optional `netlify.toml`:
      
      ```toml
      [build]
        command = "vite build"
        publish = "dist/client"
      [dev]
        command = "vite dev"
        port = 3000
      ```
      
      ### Railway (Official Partner)
      
      Railway provides zero-config deployment. Follow the Nitro setup, then:
      
      1. Push code to a GitHub repository
      2. Connect the repository at railway.com
      3. Railway auto-detects build settings and deploys
      
      Railway provides automatic deployments on push, built-in databases, preview environments for PRs, automatic HTTPS, and custom domains.
      
      ### Nitro (Multi-Target)
      
      Nitro is an agnostic deployment layer supporting a wide range of hosting providers.
      
      Install nitro nightly in `package.json`:
      
      ```json
      {
        "dependencies": {
          "nitro": "npm:nitro-nightly@latest"
        }
      }
      ```
      
      Vite configuration:
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import { nitro } from 'nitro/vite'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [tanstackStart(), nitro(), viteReact()],
      })
      ```
      
      #### FastResponse for Node.js
      
      When deploying to Node.js with Nitro, replace the global `Response` with srvx's optimized `FastResponse` for approximately 5% throughput improvement:
      
      ```bash
      npm install srvx
      ```
      
      ```tsx
      // src/server.ts
      import { FastResponse } from 'srvx'
      globalThis.Response = FastResponse
      
      import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
      
      export default createServerEntry({
        fetch(request) {
          return handler.fetch(request)
        },
      })
      ```
      
      ### Vercel
      
      Follow the Nitro setup. Deploy via Vercel's one-click deployment or CLI.
      
      ### Node.js / Docker
      
      Follow the Nitro setup. Package scripts:
      
      ```json
      {
        "scripts": {
          "build": "vite build",
          "start": "node .output/server/index.mjs"
        }
      }
      ```
      
      ### Bun
      
      Requires React 19+. Follow the Nitro setup with the `bun` preset:
      
      ```tsx
      // vite.config.ts
      import { defineConfig } from 'vite'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import { nitro } from 'nitro/vite'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [tanstackStart(), nitro({ preset: 'bun' }), viteReact()],
      })
      ```
      
      Build and run:
      
      ```bash
      bun run build
      bun run .output/server/index.mjs
      ```
      
      ## Entry Points
      
      Both entry points are optional. TanStack Start provides defaults if they are not present.
      
      ### Server Entry Point (src/server.ts)
      
      The server entry point uses the universal fetch handler format (WinterCG-compatible).
      
      Default implementation:
      
      ```tsx
      // src/server.ts
      import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
      
      export default createServerEntry({
        fetch(request) {
          return handler.fetch(request)
        },
      })
      ```
      
      Custom server handler with additional logic:
      
      ```tsx
      // src/server.ts
      import {
        createStartHandler,
        defaultStreamHandler,
        defineHandlerCallback,
      } from '@tanstack/react-start/server'
      import { createServerEntry } from '@tanstack/react-start/server-entry'
      
      const customHandler = defineHandlerCallback((ctx) => {
        // Custom logic before rendering
        return defaultStreamHandler(ctx)
      })
      
      const fetch = createStartHandler(customHandler)
      
      export default createServerEntry({
        fetch,
      })
      ```
      
      #### Typed Request Context
      
      Pass typed data through the request lifecycle via module augmentation:
      
      ```tsx
      // src/server.ts
      import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
      
      type MyRequestContext = {
        hello: string
        foo: number
      }
      
      declare module '@tanstack/react-start' {
        interface Register {
          server: {
            requestContext: MyRequestContext
          }
        }
      }
      
      export default createServerEntry({
        async fetch(request) {
          return handler.fetch(request, { context: { hello: 'world', foo: 123 } })
        },
      })
      ```
      
      The registered context is available throughout global middleware, request/function middleware, server routes, server functions, and the router.
      
      ### Client Entry Point (src/client.tsx)
      
      Hydrates the server-rendered HTML into an interactive application.
      
      Default implementation:
      
      ```tsx
      // src/client.tsx
      import { StartClient } from '@tanstack/react-start/client'
      import { StrictMode } from 'react'
      import { hydrateRoot } from 'react-dom/client'
      
      hydrateRoot(
        document,
        <StrictMode>
          <StartClient />
        </StrictMode>,
      )
      ```
      
      With error boundary:
      
      ```tsx
      // src/client.tsx
      import { StartClient } from '@tanstack/react-start/client'
      import { StrictMode } from 'react'
      import { hydrateRoot } from 'react-dom/client'
      import { ErrorBoundary } from './components/ErrorBoundary'
      
      hydrateRoot(
        document,
        <StrictMode>
          <ErrorBoundary>
            <StartClient />
          </ErrorBoundary>
        </StrictMode>,
      )
      ```
      
      ## Environment Variables
      
      ### Client vs Server Access
      
      | Prefix | Accessible Where | Access Pattern |
      |--------|-----------------|----------------|
      | `VITE_` | Client + Server | `import.meta.env.VITE_*` |
      | No prefix | Server only | `process.env.*` |
      
      ```tsx
      // Server function - access any variable
      const getUser = createServerFn().handler(async () => {
        const db = await connect(process.env.DATABASE_URL) // Server-only, no prefix
        return db.user.findFirst()
      })
      
      // Client component - only VITE_ prefixed variables
      function AppHeader() {
        return <h1>{import.meta.env.VITE_APP_NAME}</h1>
      }
      ```
      
      ### .env File Hierarchy
      
      Files are loaded in this order (later files override earlier ones):
      
      ```
      .env                # Default variables (commit to git)
      .env.development    # Development-specific
      .env.production     # Production-specific
      .env.local          # Local overrides (add to .gitignore)
      ```
      
      ### Type Safety for Environment Variables
      
      ```tsx
      // src/env.d.ts
      /// <reference types="vite/client" />
      
      interface ImportMetaEnv {
        readonly VITE_APP_NAME: string
        readonly VITE_API_URL: string
      }
      
      interface ImportMeta {
        readonly env: ImportMetaEnv
      }
      
      declare global {
        namespace NodeJS {
          interface ProcessEnv {
            readonly DATABASE_URL: string
            readonly JWT_SECRET: string
          }
        }
      }
      
      export {}
      ```
      
      ### Runtime Client Environment Variables
      
      `VITE_` variables are replaced at build time. For runtime variables on the client, pass them from the server via a loader:
      
      ```tsx
      const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
        return process.env.MY_RUNTIME_VAR
      })
      
      export const Route = createFileRoute('/')({
        loader: async () => {
          const runtimeValue = await getRuntimeVar()
          return { runtimeValue }
        },
        component: RouteComponent,
      })
      
      function RouteComponent() {
        const { runtimeValue } = Route.useLoaderData()
        // Use the runtime variable
      }
      ```
      
      ## Route-Level Headers
      
      The `headers` property on a route sets response headers. This is the primary mechanism for cache control, ISR, and custom headers:
      
      ```tsx
      export const Route = createFileRoute('/products/$id')({
        loader: async ({ params }) => fetchProduct(params.id),
        headers: () => ({
          'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
          'X-Custom-Header': 'my-value',
        }),
        component: ProductPage,
      })
      ```
      
      Headers can also be dynamic based on loader data or other runtime state.
      
      ## Best Practices
      
      ### 1. Use Full SSR for SEO-Critical Pages
      
      Content pages, blog posts, product pages, and landing pages should use `ssr: true` (the default) so crawlers receive complete HTML.
      
      ### 2. Use ssr: false for Authenticated Dashboards
      
      User dashboards, admin panels, and account settings rarely need SEO. Disabling SSR avoids serializing user data in the initial HTML and simplifies hydration.
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        ssr: false,
        pendingComponent: DashboardSkeleton,
        loader: () => fetchDashboardData(),
        component: Dashboard,
      })
      ```
      
      ### 3. Use ssr: 'data-only' for Browser-API-Dependent Components
      
      When you need server-fetched data but the component itself requires browser APIs (canvas, WebGL, etc.), use `'data-only'` to get server data performance without SSR rendering issues.
      
      ### 4. Combine Static Prerendering with ISR
      
      Prerender at build time for instant first loads, then use `Cache-Control` headers for ongoing freshness. This gives the best of both worlds - fast initial deployment and automatic content updates.
      
      ### 5. Start with Conservative Cache Times
      
      Begin with shorter cache durations and increase as you understand your content update patterns:
      
      ```tsx
      // Start conservative
      headers: () => ({
        'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
      })
      
      // Increase after monitoring
      headers: () => ({
        'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
      })
      ```
      
      ### 6. Keep Secrets Server-Side
      
      Never use `VITE_` prefix for sensitive values. Access secrets via `process.env` inside server functions:
      
      ```tsx
      // WRONG: secret in client bundle
      const key = import.meta.env.VITE_SECRET_KEY
      
      // CORRECT: secret stays on server
      const getData = createServerFn().handler(async () => {
        const response = await fetch(url, {
          headers: { Authorization: `Bearer ${process.env.SECRET_KEY}` },
        })
        return response.json()
      })
      ```
      
      ### 7. Use SPA Mode for Internal Tools
      
      Internal dashboards, admin tools, and other apps without SEO requirements benefit from SPA mode's simpler deployment and lower hosting costs.
      
      ### 8. Always Provide pendingComponent for Non-SSR Routes
      
      Routes with `ssr: false` or `ssr: 'data-only'` should define a `pendingComponent` to avoid blank screens during client-side loading.
      
      ## Official Documentation
      
      - [Selective SSR](https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr)
      - [SPA Mode](https://tanstack.com/start/latest/docs/framework/react/guide/spa-mode)
      - [Static Prerendering](https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering)
      - [ISR](https://tanstack.com/start/latest/docs/framework/react/guide/isr)
      - [Hosting](https://tanstack.com/start/latest/docs/framework/react/guide/hosting)
      - [Server Entry Point](https://tanstack.com/start/latest/docs/framework/react/guide/server-entry-point)
      - [Client Entry Point](https://tanstack.com/start/latest/docs/framework/react/guide/client-entry-point)
      - [Environment Variables](https://tanstack.com/start/latest/docs/framework/react/guide/environment-variables)
      - [Execution Model](https://tanstack.com/start/latest/docs/framework/react/guide/execution-model)
      
    • start-guide.md 14.7 KB
      
      # TanStack Start
      
      Full-stack React framework powered by TanStack Router and Vite. Adds SSR, streaming, server functions, middleware, server routes, and universal deployment to TanStack Router's type-safe routing.
      
      > TanStack Start is Pre-1.0. The API is stable and feature-complete, preparing for 1.0. React Server Components are available as an experimental feature - opt in with `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc` (requires React 19, Vite 7+).
      
      ## When to Use This Skill
      
      - Building full-stack React with SSR, SSG, or streaming
      - Adding server functions (type-safe RPCs) to a React app
      - Creating API/server routes alongside frontend routes
      - Implementing middleware for auth, logging, or request handling
      - Deploying to Cloudflare Workers, Netlify, Vercel, Node.js, Bun, or Docker
      - Need SPA mode with optional server functions (no SSR required)
      
      **Use TanStack Router alone** (see `router-guide.md`) when you only need client-side routing without server features.
      
      > For routing concepts (file-based routing, search params, nested layouts, loaders, preloading), see `router-guide.md`. This guide covers Start-specific full-stack features.
      
      ## Quick Start Workflow
      
      ### 1. Create Project
      
      ```bash
      pnpm create @tanstack/start@latest
      ```
      
      ### 2. Manual Setup
      
      ```bash
      npm i @tanstack/react-start @tanstack/react-router react react-dom
      npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom vite-tsconfig-paths
      ```
      
      ### 3. Vite Configuration
      
      ```ts
      // vite.config.ts
      import { defineConfig } from 'vite'
      import tsConfigPaths from 'vite-tsconfig-paths'
      import { tanstackStart } from '@tanstack/react-start/plugin/vite'
      import viteReact from '@vitejs/plugin-react'
      
      export default defineConfig({
        server: { port: 3000 },
        plugins: [
          tsConfigPaths(),
          tanstackStart(),
          viteReact(), // MUST come after tanstackStart()
        ],
      })
      ```
      
      ### 4. Router and Root Route
      
      ```tsx
      // src/router.tsx
      import { createRouter } from '@tanstack/react-router'
      import { routeTree } from './routeTree.gen'
      
      export function getRouter() {
        return createRouter({ routeTree, scrollRestoration: true })
      }
      ```
      
      ```tsx
      // src/routes/__root.tsx
      /// <reference types="vite/client" />
      import type { ReactNode } from 'react'
      import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'
      
      export const Route = createRootRoute({
        head: () => ({
          meta: [
            { charSet: 'utf-8' },
            { name: 'viewport', content: 'width=device-width, initial-scale=1' },
            { title: 'My TanStack Start App' },
          ],
        }),
        component: () => (
          <html>
            <head><HeadContent /></head>
            <body><Outlet /><Scripts /></body>
          </html>
        ),
      })
      ```
      
      ### 5. Route with Server Function
      
      ```tsx
      // src/routes/index.tsx
      import { createFileRoute } from '@tanstack/react-router'
      import { createServerFn } from '@tanstack/react-start'
      
      const getServerTime = createServerFn({ method: 'GET' }).handler(async () => {
        return new Date().toISOString()
      })
      
      export const Route = createFileRoute('/')({
        loader: () => getServerTime(),
        component: () => {
          const time = Route.useLoaderData()
          return <div>Server time: {time}</div>
        },
      })
      ```
      
      ### File Structure
      
      ```
      src/
      ├── routes/
      │   ├── __root.tsx        # HTML shell, always rendered
      │   └── index.tsx
      ├── router.tsx            # Router config
      ├── routeTree.gen.ts      # Auto-generated
      ├── start.ts              # Optional: global middleware
      └── server.ts             # Optional: custom server entry
      ```
      
      ## Execution Model
      
      All code is **isomorphic by default** - runs in both server and client bundles unless constrained. Route `loader`s run on the server during SSR AND on the client during navigation.
      
      ```tsx
      // WRONG - secret exposed to client bundle
      export const Route = createFileRoute('/users')({
        loader: () => {
          const secret = process.env.SECRET
          return fetch(`/api/users?key=${secret}`)
        },
      })
      
      // CORRECT - server function keeps secrets server-side
      const getUsers = createServerFn().handler(async () => {
        return fetch(`/api/users?key=${process.env.SECRET}`)
      })
      
      export const Route = createFileRoute('/users')({
        loader: () => getUsers(),
      })
      ```
      
      | API | Runs On | Client Behavior |
      |-----|---------|-----------------|
      | `createServerFn()` | Server | Network request (RPC) |
      | `createServerOnlyFn(fn)` | Server | Throws error |
      | `createClientOnlyFn(fn)` | Client | Works normally |
      | `createIsomorphicFn()` | Both | Environment-specific impl |
      | `<ClientOnly>` | Client | Renders fallback on server |
      
      ## Server Functions
      
      Type-safe RPCs via `createServerFn()`. Server code is extracted from client bundles at build time; client calls become `fetch` requests.
      
      ```tsx
      import { createServerFn } from '@tanstack/react-start'
      import { z } from 'zod'
      import { redirect, notFound } from '@tanstack/react-router'
      
      // GET with no input
      export const getData = createServerFn({ method: 'GET' }).handler(async () => {
        return { message: 'Hello from server!' }
      })
      
      // POST with Zod validation
      const CreatePostSchema = z.object({
        title: z.string().min(1).max(200),
        body: z.string().min(1),
      })
      
      export const createPost = createServerFn({ method: 'POST' })
        .validator(CreatePostSchema)
        .handler(async ({ data }) => {
          return await db.posts.create(data)
        })
      
      // Redirect and notFound
      export const getPost = createServerFn()
        .validator((data: { id: string }) => data)
        .handler(async ({ data }) => {
          const post = await db.findPost(data.id)
          if (!post) throw notFound()
          return post
        })
      ```
      
      ### Calling Server Functions
      
      ```tsx
      // From loader
      export const Route = createFileRoute('/posts')({
        loader: () => getPosts(),
      })
      
      // From component with useServerFn
      import { useServerFn } from '@tanstack/react-start'
      
      function CreatePostForm() {
        const mutation = useServerFn(createPost)
        return <button onClick={() => mutation({ data: { title: 'New', body: 'Content' } })}>Create</button>
      }
      
      // Direct call with router.invalidate()
      function DeleteButton({ id }: { id: string }) {
        const router = useRouter()
        return <button onClick={() => deletePost({ data: { id } }).then(() => router.invalidate())}>Delete</button>
      }
      ```
      
      Server-function inputs/outputs are checked for serializability (`strict: true` default). Opt out per function with `createServerFn({ strict: false })` (or `{ input: false }` / `{ output: false }`) only when you know the runtime can serialize the value.
      
      ### Server Context Utilities
      
      Access request/response from `@tanstack/react-start/server`: `getRequest()`, `getRequestHeader(name)`, `setResponseHeaders(headers)`, `setResponseStatus(code)`.
      
      ## Middleware
      
      Two types: **request middleware** (all server requests including SSR) and **server function middleware** (server functions only, with client-side hooks and input validation).
      
      ### Request Middleware
      
      ```tsx
      import { createMiddleware } from '@tanstack/react-start'
      
      const loggingMiddleware = createMiddleware().server(async ({ next, request }) => {
        const start = Date.now()
        const result = await next()
        console.log(`${request.method} ${request.url} - ${Date.now() - start}ms`)
        return result
      })
      ```
      
      ### Server Function Middleware with Context
      
      ```tsx
      const authMiddleware = createMiddleware({ type: 'function' })
        .server(async ({ next }) => {
          const user = await getCurrentUser()
          if (!user) throw redirect({ to: '/login' })
          return next({ context: { user } })
        })
      
      const getProfile = createServerFn()
        .middleware([authMiddleware])
        .handler(async ({ context }) => {
          return context.user // typed
        })
      ```
      
      ### Client + Server Middleware
      
      ```tsx
      const authHeaderMiddleware = createMiddleware({ type: 'function' })
        .client(async ({ next }) => {
          return next({ headers: { Authorization: `Bearer ${getToken()}` } })
        })
        .server(async ({ next }) => {
          const user = await verifyToken(getRequestHeader('Authorization'))
          return next({ context: { user } })
        })
      ```
      
      ### Global Middleware (src/start.ts)
      
      ```tsx
      import { createStart, createMiddleware } from '@tanstack/react-start'
      
      export const startInstance = createStart(() => ({
        requestMiddleware: [globalLogger],  // ALL requests (SSR, routes, fns)
        functionMiddleware: [globalAuth],   // ALL server functions
      }))
      ```
      
      ## Server Routes
      
      HTTP endpoints alongside frontend routes using file-based routing. Handlers receive `{ request, params, context }` and return `Response`.
      
      ```tsx
      // src/routes/api/users.ts
      import { createFileRoute } from '@tanstack/react-router'
      
      export const Route = createFileRoute('/api/users')({
        server: {
          middleware: [authMiddleware],
          handlers: {
            GET: async ({ request }) => {
              return Response.json(await db.users.findMany())
            },
            POST: async ({ request }) => {
              const body = await request.json()
              return Response.json(await db.users.create(body), { status: 201 })
            },
          },
        },
      })
      ```
      
      Per-handler middleware via `createHandlers`:
      
      ```tsx
      server: {
        handlers: ({ createHandlers }) => createHandlers({
          GET: async ({ request }) => Response.json({ ok: true }),
          DELETE: {
            middleware: [adminOnlyMiddleware],
            handler: async ({ request }) => Response.json({ deleted: true }),
          },
        }),
      }
      ```
      
      Server routes and components can co-exist in the same file. Dynamic params (`$id`), wildcards (`$`), and escaped matching (`[.]json`) all work identically to Router.
      
      ## SSR Modes
      
      Per-route SSR control via the `ssr` property:
      
      | Mode | Loaders | Component | Use Case |
      |------|---------|-----------|----------|
      | `true` (default) | Server + Client | Server + Client | SEO, performance |
      | `false` | Client only | Client only | Browser APIs, canvas |
      | `'data-only'` | Server + Client | Client only | Dashboards |
      | `(params, search) => ...` | Dynamic | Dynamic | Conditional SSR |
      
      ```tsx
      export const Route = createFileRoute('/dashboard')({
        ssr: 'data-only',
        loader: () => getDashboardData(),
        component: Dashboard,
      })
      ```
      
      ### SPA Mode
      
      Ship static HTML shells with server function support but no SSR:
      
      ```ts
      // vite.config.ts
      tanstackStart({ spa: { enabled: true } })
      ```
      
      ### Global Default
      
      ```tsx
      // src/start.ts
      export const startInstance = createStart(() => ({ defaultSsr: false }))
      ```
      
      ## Head Management and SEO
      
      ```tsx
      export const Route = createFileRoute('/posts/$postId')({
        loader: async ({ params }) => ({ post: await getPost({ data: { id: params.postId } }) }),
        head: ({ loaderData }) => ({
          meta: [
            { title: loaderData.post.title },
            { name: 'description', content: loaderData.post.excerpt },
            { property: 'og:title', content: loaderData.post.title },
            { property: 'og:image', content: loaderData.post.coverImage },
            { name: 'twitter:card', content: 'summary_large_image' },
          ],
          links: [{ rel: 'canonical', href: `https://myapp.com/posts/${loaderData.post.id}` }],
        }),
        component: PostPage,
      })
      ```
      
      ## Authentication
      
      ### Session Management
      
      ```tsx
      // utils/session.ts
      import { useSession } from '@tanstack/react-start/server'
      
      export function useAppSession() {
        return useSession<{ userId?: string; email?: string }>({
          name: 'app-session',
          password: process.env.SESSION_SECRET!,
          cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true },
        })
      }
      ```
      
      ### Route Protection
      
      ```tsx
      // src/routes/_authed.tsx - layout route guard
      export const Route = createFileRoute('/_authed')({
        beforeLoad: async ({ location }) => {
          const user = await getCurrentUserFn()
          if (!user) throw redirect({ to: '/login', search: { redirect: location.href } })
          return { user }
        },
      })
      
      // src/routes/_authed/dashboard.tsx - automatically protected
      export const Route = createFileRoute('/_authed/dashboard')({
        component: () => {
          const { user } = Route.useRouteContext()
          return <h1>Welcome, {user.email}!</h1>
        },
      })
      ```
      
      ## Environment Functions
      
      ```tsx
      import { createIsomorphicFn, createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start'
      import { ClientOnly } from '@tanstack/react-router'
      
      const getDeviceInfo = createIsomorphicFn()
        .server(() => ({ type: 'server', platform: process.platform }))
        .client(() => ({ type: 'client', userAgent: navigator.userAgent }))
      
      const getDbUrl = createServerOnlyFn(() => process.env.DATABASE_URL) // throws on client
      const saveLocal = createClientOnlyFn((k: string, v: string) => localStorage.setItem(k, v)) // throws on server
      
      // Component-level: renders fallback during SSR, children after hydration
      <ClientOnly fallback={<div>Loading...</div>}><InteractiveChart /></ClientOnly>
      ```
      
      ## Deployment
      
      ### Cloudflare Workers (Official Partner)
      
      Install `@cloudflare/vite-plugin` and `wrangler`, add `cloudflare({ viteEnvironment: { name: 'ssr' } })` to vite plugins (before `tanstackStart()`), and set `"main": "@tanstack/react-start/server-entry"` in `wrangler.jsonc`.
      
      ### Netlify (Official Partner)
      
      Install `@netlify/vite-plugin-tanstack-start`, add `netlify()` to vite plugins alongside `tanstackStart()`.
      
      ### Nitro (Node.js, Vercel, Bun, Docker)
      
      Install `nitro@npm:nitro-nightly@latest`, add `nitro()` to vite plugins. Build with `vite build`, run with `node .output/server/index.mjs`.
      
      ### Static Prerendering
      
      ```ts
      tanstackStart({ prerender: { enabled: true, crawlLinks: true } })
      ```
      
      ## Best Practices
      
      1. **Never put secrets in loaders** - Loaders are isomorphic. Use `createServerFn()` for server-only access.
      2. **Server functions are the boundary** - Primary mechanism for safe server-only execution from client code.
      3. **Organize by concern** - `.functions.ts` for server fn wrappers, `.server.ts` for internal helpers, `.ts` for shared types/schemas.
      4. **Compose middleware hierarchically** - Global for cross-cutting concerns, route-level for groups, function-level for specifics.
      5. **Use `head()` on every content route** - Title, description, OG tags. Use loader data for dynamic pages.
      6. **Choose SSR mode per route** - `true` for SEO, `false` for browser-only, `'data-only'` for dashboards.
      7. **Validate all server function inputs** - Zod or custom validators via `.validator()`.
      
      ## Advanced Topics
      
      For deeper coverage, see reference files:
      
      - `server-functions.md` - Streaming, FormData, progressive enhancement, request cancellation, custom function IDs
      - `middleware.md` - sendContext, custom fetch, global config, environment tree shaking
      - `ssr-modes.md` - Selective SSR inheritance, functional form, shellComponent, fallback rendering
      - `server-routes.md` - Dynamic params, wildcards, escaped matching, pathless layouts
      
      ## Resources
      
      - [Official Docs](https://tanstack.com/start/latest/docs/framework/react/overview)
      - [GitHub](https://github.com/TanStack/router) (Start lives in the router repo)
      - [Examples](https://github.com/TanStack/router/tree/main/examples/react) - Basic, Auth, React Query, Cloudflare, Clerk, Supabase
      - [Start vs Next.js](https://tanstack.com/start/latest/docs/framework/react/comparison)
      - Cross-reference: `router-guide.md` for routing, `query-guide.md` for data fetching
      
  • CHANGELOG.md 5.9 KB
    # Changelog
    
    All notable changes to this skill will be documented in this file.
    
    The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
    and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
    
    ## [Unreleased]
    
    ## [0.4.5] - 2026-09-09
    
    ### Changed
    - Description condensed to fit the repo's 250-character limit.
    
    ## [0.4.4] - 2026-08-21
    
    ### Changed
    
    - Declared ClawHub browse categories (`development`) and topics in `metadata`, so the release pipeline publishes them instead of leaving the skill in the `other` category.
    
    ### Removed
    
    - `skill-card.md`. The ClawHub CLI strips a root `skill-card.md` from every publish and the registry generates its own card, so the authored file never reached ClawHub.
    
    ## [0.4.3] - 2026-08-07
    
    ### Changed
    
    - Trimmed the frontmatter description to what-plus-when; dropped the trailing 14-item trigger-keyword list.
    
    ## [0.4.2] - 2026-07-22
    
    ### Added
    
    - skill-card.md release record following NVIDIA's skill-card format
    - metadata.openclaw block (emoji, homepage) for ClawHub display
    
    ## [0.4.1] - 2026-07-10
    
    ### Changed
    - CHANGELOG preamble pinned to Keep a Changelog 2.0.0 (format unchanged; KaC 2.0.0 keeps existing changelogs valid).
    
    ## [0.4.0] - 2026-07-01
    
    ### Fixed
    - infinite-queries.md: removed the v4-only `refetchPage` invalidation API (deleted
      in Query v5) from three examples; replaced with the `maxPages` option, which caps
      stored and refetched pages (TanStack Query migrating-to-v5: "Removed `refetchPage`
      in favor of `maxPages`").
    
    ### Added
    - Query: `useIsFetching` / `useIsMutating` for app-wide loading/mutating indicators
      (query-guide.md).
    - Query: `maxPages` option to cap stored/refetched pages in infinite queries
      (infinite-queries.md).
    - Query: `usePrefetchQuery` / `usePrefetchInfiniteQuery` render-phase prefetch hooks,
      distinct from the imperative `queryClient.prefetchQuery` (query-performance.md).
    - Query: referential-stability footgun - `data ?? []` and per-render new objects/proxy
      getters in queryKeys or effect deps cause infinite refetch loops / "Maximum update
      depth exceeded"; use hoisted stable constants (query-guide.md).
    - Router: View Transitions - `viewTransition` on Link/navigate, `defaultViewTransition`
      on the router (routing-patterns.md).
    - Router: route lifecycle callbacks `onEnter` / `onStay` / `onLeave` and `remountDeps`
      remount control (routing-patterns.md).
    - Router: `createLink` / `useLinkProps` for custom, type-safe Link components wrapping
      UI-library anchors (router-guide.md).
    - Start: testing a server function via curl requires the `x-tsr-serverFn: true` header,
      else the handler never runs; thrown errors return HTTP 200 with the error framed in
      the body (server-functions.md).
    
    Verified against: @tanstack/react-query@5.101.2, @tanstack/react-router@1.170.16, @tanstack/react-start@1.168.26
    
    ## [0.3.0] - 2026-06-10
    
    ### Changed
    - Server functions & middleware: renamed `.inputValidator()` -> `.validator()`
      across SKILL.md, server-functions.md, middleware.md, start-guide.md (29 call
      sites). `validator()` is now the canonical method; `inputValidator()` is
      deprecated and the compiler emits warnings for it (TanStack/router PR #7566).
    - start-guide.md: dropped the stale "Release Candidate" header and the "No RSC
      support yet" claim, aligning it with SKILL.md (Pre-1.0; RSC experimental opt-in).
    
    ### Added
    - Query cross-reload persistence setup (query-performance.md):
      `PersistQueryClientProvider` + storage persister, `gcTime >= maxAge`, `buster`
      for cache-shape changes, and excluding credential-bearing queries via
      `shouldDehydrateQuery`; corrected the React package name to
      `@tanstack/react-query-persist-client`.
    - Network Mode note (`online` / `always` / `offlineFirst`) in query-performance.md.
    - `useMutationState` note for shared cross-component mutation UI in query-guide.md.
    
    Verified against: @tanstack/react-router@1.170.15, @tanstack/react-start@1.168.25, @tanstack/router-plugin@1.168.18
    
    ## [0.2.0] - 2026-06-04
    
    ### Changed
    - Start: dropped the "(RC)" label and the "No RSC yet" claim. The docs overview no
      longer uses RC; React Server Components are now an experimental feature (opt-in via
      `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc`, React 19 / Vite 7+).
    - Start scaffolding command updated from `pnpm create @tanstack/start@latest` to
      `npx @tanstack/cli@latest create` (or TanStack Builder).
    - Deployment: Cloudflare, Netlify, and Railway are now the official hosting partners.
    - Sharpened the auth best practice: the security boundary is the server function /
      server route / endpoint that touches private data, not `beforeLoad` route guards
      (SKILL.md best practice, middleware.md, server-functions.md).
    
    ### Added
    - Zod v4 note: the zod-adapter is no longer needed with Zod v4 (pass the schema
      directly to `validateSearch`; `.catch()` retains types).
    - CSRF default-middleware caveat: defining `src/start.ts` requires re-adding
      `createCsrfMiddleware()` explicitly for server functions.
    - `createServerFn({ strict })` serialization-check opt-out (server-functions.md, start-guide.md).
    - Native `mutationOptions()` helper (companion to `queryOptions()`) in query-guide.md.
    - Server-function Cache-Control safety: `public` only for non-identity data; authed
      responses need `private` + `Vary` or `no-store` (server-functions.md).
    - Real-world footguns: invalidate-then-navigate stale data, queryKey runtime
      dimensions, StrictMode double-submit, `staleTime` is not cross-reload persistence
      (Query); dot-prefixed dirs excluded from the route tree, `Link to` rejects dynamic
      strings, client-only-provider hydration mismatches (Router/Start).
    
    ### Fixed
    - Stale Start doc URLs updated to include the `/guide/` path segment.
    
    - Initial CHANGELOG; upstream tracking established.
    
    Verified against: @tanstack/react-query@5.101.0, @tanstack/react-router@1.170.11, @tanstack/react-start@1.168.19, @tanstack/zod-adapter@1.167.0, @tanstack/router-plugin@1.168.14
    
  • LICENSE.txt 8.9 KB
    Apache License
    Version 2.0, January 2004
    https://www.apache.org/licenses/
    
    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    
    1. Definitions.
    
    "License" shall mean the terms and conditions for use, reproduction, and
    distribution as defined by Sections 1 through 9 of this document.
    
    "Licensor" shall mean the copyright owner or entity authorized by the
    copyright owner that is granting the License.
    
    "Legal Entity" shall mean the union of the acting entity and all other
    entities that control, are controlled by, or are under common control with
    that entity. For the purposes of this definition, "control" means (i) the
    power, direct or indirect, to cause the direction or management of such
    entity, whether by contract or otherwise, or (ii) ownership of fifty percent
    (50%) or more of the outstanding shares, or (iii) beneficial ownership of
    such entity.
    
    "You" (or "Your") shall mean an individual or Legal Entity exercising
    permissions granted by this License.
    
    "Source" form shall mean the preferred form for making modifications,
    including but not limited to software source code, documentation source, and
    configuration files.
    
    "Object" form shall mean any form resulting from mechanical transformation or
    translation of a Source form, including but not limited to compiled object
    code, generated documentation, and conversions to other media types.
    
    "Work" shall mean the work of authorship, whether in Source or Object form,
    made available under the License, as indicated by a copyright notice that is
    included in or attached to the work (an example is provided in the Appendix
    below).
    
    "Derivative Works" shall mean any work, whether in Source or Object form,
    that is based on (or derived from) the Work and for which the editorial
    revisions, annotations, elaborations, or other modifications represent, as a
    whole, an original work of authorship. For the purposes of this License,
    Derivative Works shall not include works that remain separable from, or
    merely link (or bind by name) to the interfaces of, the Work and Derivative
    Works thereof.
    
    "Contribution" shall mean any work of authorship, including the original
    version of the Work and any modifications or additions to that Work or
    Derivative Works thereof, that is intentionally submitted to Licensor for
    inclusion in the Work by the copyright owner or by an individual or Legal
    Entity authorized to submit on behalf of the copyright owner. For the
    purposes of this definition, "submitted" means any form of electronic, verbal,
    or written communication sent to the Licensor or its representatives,
    including but not limited to communication on electronic mailing lists, source
    code control systems, and issue tracking systems that are managed by, or on
    behalf of, the Licensor for the purpose of discussing and improving the Work,
    but excluding communication that is conspicuously marked or otherwise
    designated in writing by the copyright owner as "Not a Contribution."
    
    "Contributor" shall mean Licensor and any individual or Legal Entity on
    behalf of whom a Contribution has been received by Licensor and subsequently
    incorporated within the Work.
    
    2. Grant of Copyright License. Subject to the terms and conditions of this
    License, each Contributor hereby grants to You a perpetual, worldwide,
    non-exclusive, no-charge, royalty-free, irrevocable copyright license to
    reproduce, prepare Derivative Works of, publicly display, publicly perform,
    sublicense, and distribute the Work and such Derivative Works in Source or
    Object form.
    
    3. Grant of Patent License. Subject to the terms and conditions of this
    License, each Contributor hereby grants to You a perpetual, worldwide,
    non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
    section) patent license to make, have made, use, offer to sell, sell, import,
    and otherwise transfer the Work, where such license applies only to those
    patent claims licensable by such Contributor that are necessarily infringed by
    their Contribution(s) alone or by combination of their Contribution(s) with
    the Work to which such Contribution(s) was submitted. If You institute patent
    litigation against any entity (including a cross-claim or counterclaim in a
    lawsuit) alleging that the Work or a Contribution incorporated within the Work
    constitutes direct or contributory patent infringement, then any patent
    licenses granted to You under this License for that Work shall terminate as of
    the date such litigation is filed.
    
    4. Redistribution. You may reproduce and distribute copies of the Work or
    Derivative Works thereof in any medium, with or without modifications, and in
    Source or Object form, provided that You meet the following conditions:
    
    (a) You must give any other recipients of the Work or Derivative Works a copy
    of this License; and
    
    (b) You must cause any modified files to carry prominent notices stating that
    You changed the files; and
    
    (c) You must retain, in the Source form of any Derivative Works that You
    distribute, all copyright, patent, trademark, and attribution notices from
    the Source form of the Work, excluding those notices that do not pertain to
    any part of the Derivative Works; and
    
    (d) If the Work includes a "NOTICE" text file as part of its distribution,
    then any Derivative Works that You distribute must include a readable copy of
    the attribution notices contained within such NOTICE file, excluding those
    notices that do not pertain to any part of the Derivative Works, in at least
    one of the following places: within a NOTICE text file distributed as part of
    the Derivative Works; within the Source form or documentation, if provided
    along with the Derivative Works; or, within a display generated by the
    Derivative Works, if and wherever such third-party notices normally appear.
    The contents of the NOTICE file are for informational purposes only and do not
    modify the License. You may add Your own attribution notices within Derivative
    Works that You distribute, alongside or as an addendum to the NOTICE text from
    the Work, provided that such additional attribution notices cannot be
    construed as modifying the License.
    
    You may add Your own copyright statement to Your modifications and may provide
    additional or different license terms and conditions for use, reproduction, or
    distribution of Your modifications, or for any such Derivative Works as a
    whole, provided Your use, reproduction, and distribution of the Work otherwise
    complies with the conditions stated in this License.
    
    5. Submission of Contributions. Unless You explicitly state otherwise, any
    Contribution intentionally submitted for inclusion in the Work by You to the
    Licensor shall be under the terms and conditions of this License, without any
    additional terms or conditions. Notwithstanding the above, nothing herein
    shall supersede or modify the terms of any separate license agreement you may
    have executed with Licensor regarding such Contributions.
    
    6. Trademarks. This License does not grant permission to use the trade names,
    trademarks, service marks, or product names of the Licensor, except as
    required for reasonable and customary use in describing the origin of the Work
    and reproducing the content of the NOTICE file.
    
    7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
    writing, Licensor provides the Work (and each Contributor provides its
    Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied, including, without limitation, any warranties
    or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    PARTICULAR PURPOSE. You are solely responsible for determining the
    appropriateness of using or redistributing the Work and assume any risks
    associated with Your exercise of permissions under this License.
    
    8. Limitation of Liability. In no event and under no legal theory, whether in
    tort (including negligence), contract, or otherwise, unless required by
    applicable law (such as deliberate and grossly negligent acts) or agreed to in
    writing, shall any Contributor be liable to You for damages, including any
    direct, indirect, special, incidental, or consequential damages of any
    character arising as a result of this License or out of the use or inability to
    use the Work (including but not limited to damages for loss of goodwill, work
    stoppage, computer failure or malfunction, or any and all other commercial
    damages or losses), even if such Contributor has been advised of the
    possibility of such damages.
    
    9. Accepting Warranty or Additional Liability. While redistributing the Work
    or Derivative Works thereof, You may choose to offer, and charge a fee for,
    acceptance of support, warranty, indemnity, or other liability obligations
    and/or rights consistent with this License. However, in accepting such
    obligations, You may act only on Your own behalf and on Your sole
    responsibility, not on behalf of any other Contributor, and only if You agree
    to indemnify, defend, and hold each Contributor harmless for any liability
    incurred by, or claims asserted against, such Contributor by reason of your
    accepting any such warranty or additional liability.
    
    END OF TERMS AND CONDITIONS
    
  • SKILL.md 13.2 KB
    ---
    name: tanstack
    description: Type-safe React with TanStack Query (fetching, caching, mutations), Router (file-based routing, search params, loaders), and Start (SSR, server functions). Use for react-query, server state, typed search params, route loaders, or SSR.
    metadata:
      version: "0.4.5"
      categories: "development"
      topics: "tanstack, react, routing, data-fetching, ssr"
      upstream: "@tanstack/react-query@5.101.2, @tanstack/react-router@1.170.16, @tanstack/react-start@1.168.26, @tanstack/zod-adapter@1.167.0, @tanstack/router-plugin@1.168.18"
      openclaw:
        homepage: https://github.com/tenequm/skills/tree/main/skills/tanstack
        emoji: "⚛️"
    ---
    
    # TanStack (Query + Router + Start)
    
    Type-safe libraries for React applications. **Query** manages server state (fetching, caching, mutations). **Router** provides file-based routing with validated search params and data loaders. **Start** extends Router with SSR, server functions, and middleware for full-stack apps.
    
    ## When to Use
    
    **Query** - data fetching, caching, mutations, optimistic updates, infinite scroll, streaming AI/SSE responses, tRPC v11 integration
    **Router** - file-based routing, type-safe navigation, validated search params, route loaders, code splitting, preloading
    **Start** - SSR/SSG, server functions (type-safe RPCs), middleware, API routes, deployment to Cloudflare/Vercel/Node
    
    **Decision tree:**
    - Client-only SPA with API calls -> Router + Query
    - Full-stack with SSR/server functions -> Start + Query (Start includes Router)
    
    ## TanStack Query v5
    
    ### Setup
    
    ```tsx
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    
    const queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 1000 * 60 * 5, // 5 minutes
        },
      },
    })
    
    function App() {
      return (
        <QueryClientProvider client={queryClient}>
          <YourApp />
        </QueryClientProvider>
      )
    }
    ```
    
    ### Queries
    
    ```tsx
    import { useQuery, queryOptions } from '@tanstack/react-query'
    
    // Reusable query definition (recommended pattern)
    const todosQueryOptions = queryOptions({
      queryKey: ['todos'],
      queryFn: async () => {
        const res = await fetch('/api/todos')
        if (!res.ok) throw new Error('Failed to fetch')
        return res.json() as Promise<Todo[]>
      },
    })
    
    // In component - full type inference from queryOptions
    function TodoList() {
      const { data, isLoading, error } = useQuery(todosQueryOptions)
      if (isLoading) return <Spinner />
      if (error) return <div>Error: {error.message}</div>
      return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
    }
    ```
    
    ### Mutations
    
    ```tsx
    import { useMutation, useQueryClient } from '@tanstack/react-query'
    
    function CreateTodo() {
      const queryClient = useQueryClient()
      const mutation = useMutation({
        mutationFn: (newTodo: { title: string }) =>
          fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then(r => r.json()),
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] })
        },
      })
    
      return (
        <button onClick={() => mutation.mutate({ title: 'New' })}>
          {mutation.isPending ? 'Creating...' : 'Create'}
        </button>
      )
    }
    ```
    
    ### Key Patterns
    
    **Query keys** - hierarchical arrays for cache management:
    ```tsx
    ['todos']                          // all todos
    ['todos', 'list', { page, sort }]  // filtered list
    ['todo', todoId]                   // single item
    ```
    
    **Dependent queries** - chain with `enabled`:
    ```tsx
    const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
    const { data: projects } = useQuery({
      queryKey: ['projects', user?.id],
      queryFn: () => fetchProjects(user!.id),
      enabled: !!user?.id,
    })
    ```
    
    **Important defaults**: staleTime: 0, gcTime: 5min, retry: 3, refetchOnWindowFocus: true
    
    **Suspense** - use `useSuspenseQuery` with `<Suspense>` boundaries
    
    **Streamed queries** (experimental) - for AI chat/SSE:
    ```tsx
    import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'
    
    const { data: chunks } = useQuery(queryOptions({
      queryKey: ['chat', sessionId],
      queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), refetchMode: 'reset' }),
    }))
    ```
    
    ### DevTools
    
    ```bash
    pnpm add @tanstack/react-query-devtools
    ```
    ```tsx
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
    // Add inside QueryClientProvider
    <ReactQueryDevtools initialIsOpen={false} />
    ```
    
    ### Query Deep Dives
    
    - `query-guide.md` - Complete Query reference with all patterns
    - `infinite-queries.md` - useInfiniteQuery, pagination, virtual scroll
    - `optimistic-updates.md` - Optimistic UI, rollback, undo
    - `query-performance.md` - staleTime tuning, deduplication, prefetching
    - `query-invalidation.md` - Cache invalidation strategies, filters, predicates
    - `query-typescript.md` - Type inference, generics, custom hooks
    
    ---
    
    ## TanStack Router v1
    
    ### Setup (Vite)
    
    ```bash
    pnpm add @tanstack/react-router @tanstack/router-plugin
    ```
    
    ```ts
    // 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(),
      ],
    })
    ```
    
    ```tsx
    // src/router.ts
    import { createRouter } from '@tanstack/react-router'
    import { routeTree } from './routeTree.gen'
    
    export const router = createRouter({ routeTree, defaultPreload: 'intent' })
    
    declare module '@tanstack/react-router' {
      interface Register { router: typeof router }
    }
    ```
    
    ### File-Based Routing
    
    Files in `src/routes/` auto-generate route config:
    
    | Convention | Purpose | Example |
    |---|---|---|
    | `__root.tsx` | Root route (always rendered) | `src/routes/__root.tsx` |
    | `index.tsx` | Index route | `src/routes/index.tsx` -> `/` |
    | `$param` | Dynamic segment | `posts.$postId.tsx` -> `/posts/:id` |
    | `_prefix` | Pathless layout | `_layout.tsx` wraps children |
    | `(folder)` | Route group (no URL) | `(auth)/login.tsx` -> `/login` |
    
    ### Type-Safe Navigation
    
    ```tsx
    <Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
    
    // Active styling
    <Link to="/posts" activeProps={{ className: 'font-bold' }}>Posts</Link>
    
    // Imperative
    const navigate = useNavigate({ from: '/posts' })
    navigate({ to: '/posts/$postId', params: { postId: post.id } })
    ```
    
    Always provide `from` on Link and hooks - narrows types and improves TS performance.
    
    ### Search Params
    
    ```tsx
    import { zodValidator, fallback } from '@tanstack/zod-adapter'
    import { z } from 'zod'
    
    const searchSchema = z.object({
      page: fallback(z.number(), 1).default(1),
      sort: fallback(z.enum(['newest', 'oldest']), 'newest').default('newest'),
    })
    
    export const Route = createFileRoute('/products')({
      validateSearch: zodValidator(searchSchema),
      component: () => {
        const { page, sort } = Route.useSearch()
        // Writing
        return <Link from={Route.fullPath} search={prev => ({ ...prev, page: prev.page + 1 })}>Next</Link>
      },
    })
    ```
    
    Use `fallback(...).default(...)` from the Zod adapter (Zod v3); plain `.catch()` causes type loss. With **Zod v4** the adapter is no longer needed - pass the schema directly to `validateSearch`, and `.catch()` retains type inference.
    
    ### Data Loading
    
    ```tsx
    export const Route = createFileRoute('/posts')({
      // loaderDeps: only extract what loader needs (not full search)
      loaderDeps: ({ search: { page } }) => ({ page }),
      loader: ({ deps: { page } }) => fetchPosts({ page }),
      pendingComponent: () => <Spinner />,
      component: () => {
        const posts = Route.useLoaderData()
        return <PostList posts={posts} />
      },
    })
    ```
    
    ### Route Context (Dependency Injection)
    
    ```tsx
    // __root.tsx
    interface RouterContext { queryClient: QueryClient }
    export const Route = createRootRouteWithContext<RouterContext>()({ component: Root })
    
    // router.ts
    const router = createRouter({ routeTree, context: { queryClient } })
    
    // Child route - queryClient available in loader
    export const Route = createFileRoute('/posts')({
      loader: ({ context: { queryClient } }) =>
        queryClient.ensureQueryData(postsQueryOptions()),
    })
    ```
    
    ### Router Deep Dives
    
    - `router-guide.md` - Complete Router reference with all patterns
    - `search-params.md` - Custom serialization, Standard Schema, sharing params
    - `data-loading.md` - Deferred loading, streaming SSR, shouldReload
    - `routing-patterns.md` - Virtual routes, route masking, navigation blocking
    - `code-splitting.md` - Automatic/manual splitting strategies
    - `router-ssr.md` - SSR setup, streaming, hydration
    
    ---
    
    ## TanStack Start
    
    Full-stack framework extending Router with SSR, server functions, middleware. Pre-1.0 (API stable, feature-complete, preparing for 1.0). React Server Components are available as an **experimental** feature - opt in with `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc` (requires React 19, Vite 7+). Vite is the default bundler; Rsbuild is also supported.
    
    ### Setup
    
    ```bash
    npx @tanstack/cli@latest create   # or use TanStack Builder: https://tanstack.com/builder
    ```
    
    ```ts
    // vite.config.ts
    import { defineConfig } from 'vite'
    import { tanstackStart } from '@tanstack/react-start/plugin/vite'
    import viteReact from '@vitejs/plugin-react'
    
    export default defineConfig({
      plugins: [
        tanstackStart(),
        viteReact(), // MUST come after tanstackStart()
      ],
    })
    ```
    
    ### Server Functions
    
    Type-safe RPCs. Server code extracted from client bundles at build time.
    
    ```tsx
    import { createServerFn } from '@tanstack/react-start'
    import { z } from 'zod'
    
    // GET - no input
    export const getUsers = createServerFn({ method: 'GET' })
      .handler(async () => db.users.findMany())
    
    // POST - validated input
    export const createUser = createServerFn({ method: 'POST' })
      .validator(z.object({ name: z.string(), email: z.string().email() }))
      .handler(async ({ data }) => db.users.create(data))
    
    // Call from loader
    export const Route = createFileRoute('/users')({
      loader: () => getUsers(),
      component: () => {
        const users = Route.useLoaderData()
        return <UserList users={users} />
      },
    })
    ```
    
    **Critical**: Loaders are isomorphic (run on server AND client). Never put secrets in loaders - use `createServerFn()` instead.
    
    ### Middleware
    
    ```tsx
    import { createMiddleware } from '@tanstack/react-start'
    
    const authMiddleware = createMiddleware({ type: 'function' })
      .server(async ({ next }) => {
        const user = await getCurrentUser()
        if (!user) throw redirect({ to: '/login' })
        return next({ context: { user } })
      })
    
    const getProfile = createServerFn()
      .middleware([authMiddleware])
      .handler(async ({ context }) => context.user) // typed
    ```
    
    Global middleware via `src/start.ts`:
    ```tsx
    export const startInstance = createStart(() => ({
      requestMiddleware: [logger],    // all requests
      functionMiddleware: [auth],     // all server functions
    }))
    ```
    
    **CSRF**: Start auto-installs `createCsrfMiddleware()` for server functions *only when there is no `src/start.ts`*. Once you create `src/start.ts`, add it back explicitly, or non-GET server functions lose same-origin protection:
    ```tsx
    requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), logger]
    ```
    
    ### SSR Modes
    
    | Mode | Use Case |
    |------|----------|
    | `true` (default) | SEO, performance |
    | `false` | Browser-only features |
    | `'data-only'` | Dashboards (data on server, render on client) |
    
    SPA mode: `tanstackStart({ spa: { enabled: true } })` in vite.config.ts
    
    ### Deployment
    
    - **Official partners**: Cloudflare Workers (`@cloudflare/vite-plugin`), Netlify (`@netlify/vite-plugin-tanstack-start`), Railway
    - **Node/Vercel/Bun/Docker**: via Nitro
    - **Static**: `tanstackStart({ prerender: { enabled: true, crawlLinks: true } })`
    
    ### Start Deep Dives
    
    - `start-guide.md` - Complete Start reference with all patterns
    - `server-functions.md` - Streaming, FormData, progressive enhancement
    - `middleware.md` - sendContext, custom fetch, global config
    - `ssr-modes.md` - Selective SSR, shellComponent, fallback rendering
    - `server-routes.md` - Dynamic params, wildcards, pathless layouts
    
    ---
    
    ## Best Practices
    
    1. **Use `queryOptions()` factory** for reusable, type-safe query definitions
    2. **Structure query keys hierarchically** - `['entity', 'action', { filters }]`
    3. **Set staleTime per data type** - static: `Infinity`, dynamic: `0`, moderate: `5min`
    4. **Always validate search params** with Zod via `zodValidator` + `fallback().default()`
    5. **Provide `from` on navigation** - narrows types, catches route mismatches
    6. **Use route context for DI** - pass QueryClient, auth via `createRootRouteWithContext`
    7. **Set `defaultPreload: 'intent'`** globally for perceived performance
    8. **Enforce auth at the data boundary** - authorize inside the server function, server route, or API endpoint that reads/writes private data; `beforeLoad`/route guards are UX, not the security boundary. Never put secrets in isomorphic loaders - use `createServerFn()`
    9. **Compose middleware hierarchically** - global -> route -> function
    10. **Use `head()` on every content route** for SEO (title, description, OG tags)
    
    ## Resources
    
    - **Query Docs**: https://tanstack.com/query/latest/docs/framework/react/overview
    - **Router Docs**: https://tanstack.com/router/latest/docs/framework/react/overview
    - **Start Docs**: https://tanstack.com/start/latest/docs/framework/react/overview
    - **GitHub**: https://github.com/TanStack/query | https://github.com/TanStack/router
    - **Discord**: https://discord.gg/tanstack
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related