
Tanstack Start
- 104 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-start is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-start
- AI & Agent Building
- AI-coding skill
Tanstack Start by the numbers
- 104 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,243 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-startAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 104 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Start
Full-stack React framework built on TanStack Router. Type-safe server functions via RPC, SSR/streaming, middleware composition, and deployment to Cloudflare Workers, Vercel, Netlify, AWS Lambda, and more.
RC: TanStack Start is currently in Release Candidate status. APIs may still change before the stable 1.0 release.
Package: @tanstack/react-start
Quick Reference
| Pattern | Usage |
|---|---|
createServerFn() | GET (default) — idempotent, cacheable data fetching |
createServerFn({ method: 'POST' }) | Mutations that change data |
.inputValidator(zodSchema) | Input validation before handler |
.handler(async ({ data }) => {}) | Server-side logic with typed input data |
getRequest() | Access full incoming Request inside handler/middleware |
getRequestHeader(name) | Read a single request header by name |
setResponseHeaders(headers) | Set outgoing response headers (caching, cookies) |
useServerFn(fn) | Wrap server function for component use with pending state |
createMiddleware().server(fn) | Request middleware for cross-cutting concerns |
.middleware([dep]) | Compose middleware with dependencies |
next({ context: {} }) | Pass data downstream through middleware chain |
createMiddleware({ type: 'function' }) | Function-level middleware with input validation |
requestMiddleware in createStart() | Global middleware for all requests (SSR, fns, routes) |
functionMiddleware in createStart() | Global middleware for server functions only |
createIsomorphicFn() | Different implementations per environment |
createServerOnlyFn() | Server-only utility — crashes if called from client |
createClientOnlyFn() | Client-only utility — crashes if called from server |
useSession() | Cookie-based session with encryption and secure settings |
session.update() | Update session data |
session.clear() | Clear session (logout) |
beforeLoad | Auth check before route loads |
_authenticated.tsx | Pathless layout route for grouped protection |
throw redirect({ to: '/login' }) | Redirect with return URL |
await ensureQueryData() | Block SSR on critical data |
prefetchQuery() | Start fetch, don't block SSR |
<Suspense> boundaries | Define streaming chunks |
head: ({ loaderData }) => ({}) | Meta tags, Open Graph, favicons |
head.scripts + JSON-LD | Structured data for LLMO (schema.org) |
llms.txt server route | AI system guidance file |
headers: () => ({...}) | ISR / cache-control on route definition |
server: { handlers: { GET, POST } } | API routes on createFileRoute |
ssr: false | Disable SSR for specific routes (SPA mode) |
Execution Boundaries
| Function | Runs On | Client Can Call? | Use For |
|---|---|---|---|
createServerFn() | Server | Yes (RPC stub) | Data fetching, mutations |
createServerOnlyFn() | Server | No (throws) | Secrets, DB connections |
createClientOnlyFn() | Client | N/A | localStorage, DOM APIs |
createIsomorphicFn() | Both | N/A | Per-environment implementations |
Deployment (Vite Plugins)
| Platform | Plugin | Runtime |
|---|---|---|
| Cloudflare | @cloudflare/vite-plugin | Workers |
| Netlify | @netlify/vite-plugin-tanstack-start | Node |
| Vercel | nitro/vite (preset: 'vercel') | Node |
| Node.js/Docker | nitro/vite (preset: 'node-server') | Node |
| AWS Lambda | nitro/vite (preset: 'aws-lambda') | Node |
| Bun | nitro/vite (preset: 'bun') | Bun |
| Static | nitro/vite (preset: 'static') | None |
Common Mistakes
| Mistake | Fix |
|---|---|
No .inputValidator() on server functions | Always validate with Zod schemas |
Raw fetch instead of createServerFn | Loses type safety, serialization, and code splitting |
| Mixing server/client code without boundaries | Use createServerOnlyFn / createClientOnlyFn |
| Checking auth in every handler | Use middleware composition or beforeLoad |
| Awaiting all data in loader | Only await critical data, prefetch the rest |
Date.now() in render | Pass timestamp from loader (hydration mismatch) |
Missing nodejs_compat flag | Required in wrangler.toml for Cloudflare |
| GET for mutations | Use POST for create/update/delete |
| Cookies not forwarded to external APIs | Use getRequestHeader() or createIsomorphicFn |
process.env in loader (runs on both) | Wrap in createServerFn — loaders run client-side too |
| Unvalidated server env vars | Validate with Zod in .server.ts files |
| Storing auth tokens in localStorage | Use HTTP-only cookies via useSession |
| Exposing raw DB errors to client | Catch and return user-friendly messages, log details |
| No structured data for content pages | Add JSON-LD via head.scripts for AI discoverability |
Delegation
Use this skill for TanStack Start server functions, middleware, SSR/streaming, route protection, API routes, and deployment configuration. Delegate to tanstack-router for file-based routing, search params, and data loading patterns. Delegate to tanstack-query for cache management, optimistic updates, and query patterns.
If the tanstack-form skill is available, delegate form state management, validation, and field patterns to it.If the local-first skill is available, delegate local-first architecture decisions and sync engine selection to it.If the electricsql skill is available, delegate ElectricSQL shapes, auth configuration, and write patterns to it.If the tanstack-db skill is available, delegate reactive collections, live queries, and optimistic mutation patterns to it.References
- Server Functions — createServerFn, useServerFn, validation, auth, request context, response headers, file uploads, streaming, TanStack Query integration
- Middleware — composition, function-level middleware, route-level middleware, global middleware, logging, rate limiting
- SSR and Streaming — Suspense, prerendering, ISR, cache-control, hybrid strategies, hydration safety
- Route Protection — beforeLoad, pathless layouts, session security, login/logout, header forwarding, Better Auth
- API Routes — server handlers, REST patterns, route-level middleware, webhooks, health check, server functions vs server routes
- Deployment — Vite plugins, adapter comparison, Cloudflare D1/KV/R2 bindings, Docker, prerendering
- SEO and Head Management — head property, meta tags, Open Graph, Twitter Cards, favicons, SEO helper
- LLM Optimization (LLMO) — JSON-LD structured data, schema.org, llms.txt, machine-readable endpoints, AI citation patterns
- Error Handling — discriminated unions, custom error classes, status codes, result types vs try-catch
- File Organization — entry points, plugin config, execution boundaries, env validation, .server.ts convention
- Known Issues — 10 documented issues with workarounds
- Query Integration — Router+Query setup, SSR integration, setupRouterSsrQueryIntegration, DevTools
- Integration Flows — form submission with cache update, infinite scroll, paginated tables, auth-protected routes, error handling
- Local-First Integration — shape proxy server functions, mixing server-based and local-first data, ElectricSQL deployment
- Electric Middleware — auth middleware for shape proxies, global middleware config, sendContext, function-level validation
API Routes
Basic Server Route
Server routes use createFileRoute with a server property containing handlers:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: async ({ request }) => {
return new Response('Hello, World!');
},
},
},
});REST API with Multiple Methods
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/users')({
server: {
handlers: {
GET: async ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '10');
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
});
const total = await db.users.count();
return Response.json({
data: users,
pagination: { page, limit, total },
});
},
POST: async ({ request }) => {
const body = await request.json();
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.flatten() },
{ status: 400 },
);
}
const user = await db.users.create({ data: parsed.data });
return Response.json(user, { status: 201 });
},
},
},
});RESTful Resource with PATCH and DELETE
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/posts/$id')({
server: {
handlers: {
GET: async ({ params }) => {
const post = await db.posts.findUnique({ where: { id: params.id } });
if (!post)
return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(post);
},
PATCH: async ({ params, request, context }) => {
if (!context.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const parsed = updatePostSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.flatten() },
{ status: 400 },
);
}
const updated = await db.posts.update({
where: { id: params.id, authorId: context.user.id },
data: parsed.data,
});
if (!updated) {
return Response.json(
{ error: 'Not found or forbidden' },
{ status: 404 },
);
}
return Response.json(updated);
},
DELETE: async ({ params, context }) => {
if (!context.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
await db.posts.delete({
where: { id: params.id, authorId: context.user.id },
});
return new Response(null, { status: 204 });
},
},
},
});Route-Level Middleware
Apply middleware to all handlers on a route, or per-handler:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/posts')({
server: {
middleware: [authMiddleware, loggerMiddleware],
handlers: {
GET: async ({ request }) => {
return Response.json(await db.posts.findMany());
},
POST: {
middleware: [validationMiddleware],
handler: async ({ request }) => {
const body = await request.json();
return Response.json(await db.posts.create({ data: body }), {
status: 201,
});
},
},
},
},
});Factory Form with createHandlers
For per-handler middleware, use the createHandlers factory instead of the plain object form:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/posts')({
server: {
middleware: [authMiddleware],
handlers: ({ createHandlers }) =>
createHandlers({
GET: {
handler: async ({ request }) => {
return Response.json(await db.posts.findMany());
},
},
POST: {
middleware: [validationMiddleware],
handler: async ({ request }) => {
const body = await request.json();
return Response.json(await db.posts.create({ data: body }), {
status: 201,
});
},
},
}),
},
});Route-level middleware runs first for all methods, then per-handler middleware runs before that specific handler.
Webhook Handler
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/webhooks/stripe')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return Response.json({ error: 'Missing signature' }, { status: 400 });
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
}
return Response.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return Response.json({ error: 'Webhook failed' }, { status: 400 });
}
},
},
},
});Health Check
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: async () => {
try {
await db.$queryRaw`SELECT 1`;
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
} catch (error) {
return Response.json(
{
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown',
},
{ status: 503 },
);
}
},
},
},
});Handler Context
Each handler receives:
| Property | Type | Description |
|---|---|---|
request | Request | Standard Web Fetch API Request object |
params | object | Dynamic path parameters (e.g., $id → params.id) |
context | object | Data from middleware (e.g., context.user) |
When to Use Server Routes vs Server Functions
| Use Case | Pattern |
|---|---|
| RPC-style calls | createServerFn (server functions) |
| REST API routes | createFileRoute with server.handlers |
| Webhooks | createFileRoute with server.handlers |
| Form submissions | createServerFn (easier integration) |
| Route loaders | createServerFn + loader |
Deployment
TanStack Start deploys via Vite plugins — Cloudflare and Netlify have dedicated plugins, all other platforms use the Nitro plugin with a preset.
Cloudflare Workers
// 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.toml
name = "my-app"
compatibility_date = "2026-01-21"
compatibility_flags = ["nodejs_compat"]
main = "@tanstack/react-start/server-entry"
# Optional: D1 database binding
[[d1_databases]]
binding = "DB"
database_name = "production-db"
database_id = "your-database-id"
# Optional: KV namespace binding
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-id"
# Optional: R2 bucket binding
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-bucket"
# Optional: Environment variables
[vars]
ENVIRONMENT = "production"Deploy with: wrangler deploy
Access bindings (D1, KV, R2) in server functions via request.context.cloudflare.env:
export const getUser = createServerFn().handler(async ({ request }) => {
const env = request.context.cloudflare.env;
// D1 database
const result = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
.bind(userId)
.first();
// KV storage
const cached = await env.CACHE.get('user:123', 'json');
await env.CACHE.put('user:123', JSON.stringify(result), {
expirationTtl: 3600,
});
// R2 object storage
const file = await env.BUCKET.get('avatar.png');
return result;
});Conditional logic for prerendering with bindings (bindings are unavailable at build time):
export const Route = createFileRoute('/users')({
loader: async ({ context }) => {
if (typeof context.cloudflare === 'undefined') {
return { users: [] };
}
const env = context.cloudflare.env;
const { results } = await env.DB.prepare('SELECT * FROM users').all();
return { users: results };
},
});Alternatively, disable prerendering on routes that use bindings:
export const Route = createFileRoute('/users')({
prerender: false,
loader: async ({ context }) => {
const env = context.cloudflare.env;
return { users: await env.DB.prepare('SELECT * FROM users').all() };
},
});Netlify
// 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()],
});Nitro (Vercel, Node.js, Bun, AWS Lambda, Static)
All other platforms use the nitro/vite plugin with a preset:
// 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: 'vercel' }), viteReact()],
});Nitro Presets
| Preset | Runtime | Use For |
|---|---|---|
vercel | Node | Vercel hosting |
node-server | Node | Docker, Railway, VPS |
static | None | GitHub Pages, S3, static hosts |
aws-lambda | Node | AWS serverless |
bun | Bun | Bun runtime (React 19 only) |
Vercel
Use the Nitro plugin with preset: 'vercel'. Vercel supports one-click deployment once configured.
Node.js / Railway / Docker
Use the Nitro plugin with preset: 'node-server'.
Add scripts to package.json:
{
"scripts": {
"build": "vite build",
"start": "node .output/server/index.mjs"
}
}FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY .output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]Bun
Use the Nitro plugin with preset: 'bun'. Requires React 19 — if using React 18, use the node-server preset instead.
Run with: bun .output/server/index.mjs
AWS Lambda
Use the Nitro plugin with preset: 'aws-lambda':
// 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: 'aws-lambda',
awsLambda: {
streaming: true,
},
}),
viteReact(),
],
});Deploy with SST, Serverless Framework, or AWS CDK:
# serverless.yml
service: my-tanstack-app
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
functions:
app:
handler: .output/server/index.handler
events:
- http: ANY /
- http: ANY /{proxy+}Static Export
Use the Nitro plugin with preset: 'static':
export default defineConfig({
plugins: [tanstackStart(), nitro({ preset: 'static' }), viteReact()],
});Output: .output/public (host on GitHub Pages, S3, any static host).
Adapter Comparison
| Adapter | Plugin | Runtime | Edge | Static | Best For |
|---|---|---|---|---|---|
| Cloudflare Workers | @cloudflare/vite-plugin | Workers | Yes | No | Edge-first, D1/KV/R2 |
| Cloudflare Pages | @cloudflare/vite-plugin | Workers | Yes | Yes | Static + edge functions |
| Vercel | nitro/vite (preset: 'vercel') | Node | No | Yes | One-click deploy, serverless |
| Netlify | @netlify/vite-plugin-tanstack-start | Node | No | Yes | Netlify ecosystem |
| Node.js / Docker | nitro/vite (preset: 'node-server') | Node | No | No | VPS, Railway, self-hosted |
| AWS Lambda | nitro/vite (preset: 'aws-lambda') | Node | No | No | AWS serverless |
| Bun | nitro/vite (preset: 'bun') | Bun | No | No | Bun runtime (React 19 only) |
| Static | nitro/vite (preset: 'static') | None | No | Yes | GitHub Pages, S3, CDN |
Deployment Notes
- Plugin ordering:
tanstackStart()must come beforeviteReact()in the plugins array - Cloudflare and Netlify have dedicated Vite plugins — do not use Nitro for these platforms
- Nitro is under active development — report issues with reproductions
- Edge adapters have API limitations (no file system access)
- Static preset requires all routes to be prerenderable
- Test locally with
npm run build && npm run preview
Electric Middleware
Reusable Auth Middleware
Base auth middleware that extracts and validates the session:
import { createMiddleware } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const authHeader = getRequestHeader('Authorization');
const session = await validateSession(authHeader);
return next({
context: {
session,
user: session?.user ?? null,
},
});
});Require Auth Middleware
Compose on authMiddleware to enforce authentication:
import { redirect } from '@tanstack/react-router';
export const requireAuthMiddleware = createMiddleware()
.middleware([authMiddleware])
.server(async ({ next, context }) => {
if (!context.user) {
throw redirect({ to: '/login' });
}
return next({
context: { user: context.user },
});
});Shape Proxy with Auth Middleware
Use middleware in server functions that proxy Electric shape requests:
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
const shapeProxySchema = z.object({
table: z.string(),
offset: z.string().optional(),
handle: z.string().optional(),
live: z.enum(['true', 'false']).optional(),
columns: z.string().optional(),
});
export const getShape = createServerFn()
.middleware([requireAuthMiddleware])
.inputValidator(shapeProxySchema)
.handler(async ({ data, context }) => {
const params = new URLSearchParams({
table: data.table,
where: `user_id = '${context.user.id}'`,
offset: data.offset ?? '-1',
});
if (data.handle) params.set('handle', data.handle);
if (data.live) params.set('live', data.live);
if (data.columns) params.set('columns', data.columns);
const electricUrl = process.env.ELECTRIC_URL ?? 'http://localhost:3000';
const secret = process.env.ELECTRIC_SECRET;
const response = await fetch(
`${electricUrl}/v1/shape?${params.toString()}`,
{
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
},
);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type':
response.headers.get('Content-Type') ?? 'application/json',
'electric-handle': response.headers.get('electric-handle') ?? '',
'electric-offset': response.headers.get('electric-offset') ?? '',
},
});
});Centralizing Shape Auth Across Server Functions
Multiple shape proxies can share the same middleware chain:
export const getTodoShape = createServerFn()
.middleware([requireAuthMiddleware])
.inputValidator(z.object({ offset: z.string().optional() }))
.handler(async ({ data, context }) => {
return proxyShape({
table: 'todos',
where: `user_id = '${context.user.id}'`,
offset: data.offset,
});
});
export const getNoteShape = createServerFn()
.middleware([requireAuthMiddleware])
.inputValidator(z.object({ offset: z.string().optional() }))
.handler(async ({ data, context }) => {
return proxyShape({
table: 'notes',
where: `user_id = '${context.user.id}'`,
offset: data.offset,
});
});
async function proxyShape(opts: {
table: string;
where: string;
offset?: string;
}) {
const electricUrl = process.env.ELECTRIC_URL ?? 'http://localhost:3000';
const secret = process.env.ELECTRIC_SECRET;
const params = new URLSearchParams({
table: opts.table,
where: opts.where,
offset: opts.offset ?? '-1',
});
const response = await fetch(`${electricUrl}/v1/shape?${params.toString()}`, {
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type':
response.headers.get('Content-Type') ?? 'application/json',
'electric-handle': response.headers.get('electric-handle') ?? '',
'electric-offset': response.headers.get('electric-offset') ?? '',
},
});
}Write Server Functions with Auth
Pair shape proxies (read path) with authenticated write functions:
export const createTodo = createServerFn({ method: 'POST' })
.middleware([requireAuthMiddleware])
.inputValidator(z.object({ title: z.string().min(1).max(200) }))
.handler(async ({ data, context }) => {
const result = await db.transaction(async (tx) => {
const [todo] = await tx
.insert(todos)
.values({ title: data.title, userId: context.user.id })
.returning();
const [{ txid }] = await tx.execute<{ txid: string }>(
sql`SELECT pg_current_xact_id()::text AS txid`,
);
return { todo, txid };
});
return result;
});Global Middleware Configuration
Apply middleware to all requests or all server functions in src/start.ts:
import { createStart } from '@tanstack/react-start';
import { logMiddleware, authMiddleware } from './middleware';
export default createStart({
requestMiddleware: [logMiddleware, authMiddleware],
});requestMiddleware vs functionMiddleware
| Type | Scope | Use For |
|---|---|---|
requestMiddleware | All incoming HTTP requests | Logging, CORS, rate limiting |
functionMiddleware | All createServerFn invocations | Auth context, tenant resolution |
export default createStart({
requestMiddleware: [logMiddleware],
functionMiddleware: [authMiddleware],
});requestMiddleware runs on every request including static assets and API routes. functionMiddleware runs only when a server function is invoked.
Middleware Execution Order
Middleware executes dependency-first. When a middleware declares .middleware([dep]), the dependency runs before it:
Request
→ logMiddleware (global requestMiddleware)
→ authMiddleware (global functionMiddleware or composed dep)
→ requireAuthMiddleware (depends on authMiddleware)
→ handler
→ requireAuthMiddleware (after next())
→ authMiddleware (after next())
→ logMiddleware (after next())
ResponseEach middleware wraps the next. Code before await next() runs on the way in, code after runs on the way out.
sendContext for Server-to-Client Data
Pass context data from middleware to the client via sendContext:
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const session = await getSession();
return next({
context: { user: session?.user ?? null },
sendContext: { isAuthenticated: !!session?.user },
});
});Access sendContext values in the client after the server function returns:
const getUser = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => {
return context.user;
});
const result = await getUser();sendContext is serialized and sent to the client. Only include serializable, non-sensitive data. Never put tokens, secrets, or database connections in sendContext.
Function-Level Middleware for Shape Validation
Use type: 'function' middleware to validate shape-specific input:
const shapeAuthMiddleware = createMiddleware({ type: 'function' })
.middleware([requireAuthMiddleware])
.inputValidator(z.object({ table: z.string() }))
.server(async ({ next, data, context }) => {
const allowedTables = ['todos', 'notes', 'tags'];
if (!allowedTables.includes(data.table)) {
throw new Error(`Table '${data.table}' is not allowed`);
}
return next({
context: { allowedTable: data.table, userId: context.user.id },
});
});
export const getShapeByTable = createServerFn()
.middleware([shapeAuthMiddleware])
.handler(async ({ context }) => {
return proxyShape({
table: context.allowedTable,
where: `user_id = '${context.userId}'`,
});
});Error Handling
Discriminated Union Pattern
type ApiResult<T> = { data: T } | { error: string; code: string };
export const updateUser = createServerFn({ method: 'POST' })
.inputValidator(updateUserSchema)
.handler(async ({ data }): Promise<ApiResult<User>> => {
try {
const user = await db.users.update({ where: { id: data.id }, data });
return { data: user };
} catch (error) {
console.error('updateUser failed:', error);
return { error: 'Update failed', code: 'INTERNAL_ERROR' };
}
});Custom Error Class
export class AppError extends Error {
constructor(
message: string,
public code: string,
public status: number = 400,
) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 'UNAUTHORIZED', 401);
}
}Server Function with Error Handling
import { createServerFn, notFound } from '@tanstack/react-start';
import { setResponseStatus } from '@tanstack/react-start/server';
export const getPost = createServerFn()
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } });
if (!post) throw notFound();
return post;
});
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
try {
return await db.posts.create({ data });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
setResponseStatus(409);
throw new AppError(
'A post with this title already exists',
'DUPLICATE',
409,
);
}
}
console.error('Failed to create post:', error);
setResponseStatus(500);
throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500);
}
});Centralized Error Handler
function handleServerError(error: unknown): { error: string; code: string } {
if (error instanceof AppError) {
return { error: error.message, code: error.code };
}
if (error instanceof PostgresError && error.code === '23505') {
return { error: 'Resource already exists', code: 'CONFLICT' };
}
console.error('Unhandled error:', error);
return { error: 'Internal server error', code: 'INTERNAL_ERROR' };
}Client-Side Error Handling
function CreatePostForm() {
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: createPost,
onError: (error) => {
if (error instanceof AppError) {
setError(error.message);
} else {
setError('An unexpected error occurred');
}
},
onSuccess: (post) => {
navigate({ to: '/posts/$postId', params: { postId: post.id } });
},
});
return (
<form onSubmit={handleSubmit}>
{error ? <Alert variant="error">{error}</Alert> : null}
{/* form fields */}
</form>
);
}Auth Errors with Redirects
export const updateProfile = createServerFn({ method: 'POST' })
.inputValidator(updateProfileSchema)
.handler(async ({ data }) => {
const session = await getSessionData();
if (!session) {
throw redirect({
to: '/login',
search: { redirect: '/settings' },
});
}
return await db.users.update({
where: { id: session.userId },
data,
});
});Status Code Conventions
| Scenario | Status | Code | Response |
|---|---|---|---|
| Validation failed | 400 | VALIDATION_ERROR | Field-specific errors |
| Not authenticated | 401 | AUTH_REQUIRED | Redirect to login |
| Not authorized | 403 | FORBIDDEN | Generic forbidden message |
| Resource not found | 404 | NOT_FOUND | Use notFound() |
| Conflict (duplicate) | 409 | CONFLICT | Specific conflict message |
| Server error | 500 | INTERNAL_ERROR | Generic message, log details |
Try-Catch vs Result Types
Two approaches to error handling in server functions:
Try-Catch: Exceptions as Control Flow
export const deletePost = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } });
if (!post) {
setResponseStatus(404);
throw new AppError('Post not found', 'NOT_FOUND', 404);
}
await db.posts.delete({ where: { id: data.id } });
return { success: true };
});Result Types: Errors as Values
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: string; code: string };
export const deletePost = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }): Promise<Result<{ success: true }>> => {
const post = await db.posts.findUnique({ where: { id: data.id } });
if (!post) {
return { ok: false, error: 'Post not found', code: 'NOT_FOUND' };
}
await db.posts.delete({ where: { id: data.id } });
return { ok: true, data: { success: true } };
});Comparison
| Aspect | Try-Catch | Result Types |
|---|---|---|
| Type safety | Errors are untyped | Compiler enforces checks |
| Forgotten checks | Silent runtime bugs | Type error at compile time |
| Redirect/notFound | Works naturally (throw) | Must be handled separately |
| Verbosity | Less boilerplate | More explicit |
| Composition | try-catch nesting | Flat conditional chains |
Use result types for business logic errors (validation, not found, forbidden). Use exceptions for truly exceptional cases (redirect, notFound, infrastructure failures).
Anti-Patterns
- Throwing instead of returning errors -- Return
{ error, code }format for predictable client handling - Missing error codes -- Always include both
errorandcodein error responses - Exposing raw DB errors -- Catch and return user-friendly messages, log the real error with
console.error() - Returning sensitive data in errors -- Only return what the client needs to display
File Organization
Entry Points
TanStack Start has three entry files, all optional. If omitted, defaults are auto-generated:
// src/client.tsx — Client entry point (hydrates the app)
import { StartClient } from '@tanstack/react-start/client';
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
);// src/server.ts — Server entry point (handles incoming requests)
import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
export default createServerEntry({
fetch(request) {
return handler.fetch(request);
},
});// src/start.ts — Global configuration (optional)
import { createStart } from '@tanstack/react-start';
export const startInstance = createStart(() => ({
requestMiddleware: [],
functionMiddleware: [],
}));Plugin Configuration
The tanstackStart() Vite plugin accepts configuration options:
// 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({
srcDirectory: 'src',
router: {
routesDirectory: 'app',
},
}),
viteReact(),
],
});| Option | Default | Description |
|---|---|---|
srcDirectory | 'src' | Root source directory |
router.routesDirectory | 'routes' | Directory for file-based routes |
Execution Boundaries
TanStack Start uses function-level APIs to enforce server/client separation — not file suffixes.
| Function | Runs On | Client Can Call? | Use For |
|---|---|---|---|
createServerFn() | Server | Yes (RPC stub) | Data fetching, mutations, server logic |
createServerOnlyFn() | Server | No (throws) | Secrets, DB connections, server utilities |
createClientOnlyFn() | Client | N/A | localStorage, DOM APIs, browser-only logic |
createIsomorphicFn() | Server + Client | N/A | Different implementations per environment |
All imported from @tanstack/react-start.
Execution Boundary Examples
import {
createServerFn,
createServerOnlyFn,
createClientOnlyFn,
createIsomorphicFn,
} from '@tanstack/react-start';
// RPC: runs on server, callable from client via network request
const fetchUser = createServerFn().handler(async () => {
return await db.users.findFirst();
});
// Server-only: crashes if called from client
const getSecret = createServerOnlyFn(() => process.env.DATABASE_URL);
// Client-only: crashes if called from server
const saveToStorage = createClientOnlyFn((data: unknown) => {
localStorage.setItem('data', JSON.stringify(data));
});
// Different implementations per environment
const logger = createIsomorphicFn()
.server((msg: string) => serverLogger.info(msg))
.client((msg: string) => console.log(`[CLIENT]: ${msg}`));Server Function File Convention
For larger applications, split server-side code using a three-file naming convention:
| Suffix | Purpose | Safe to import from |
|---|---|---|
.ts | Client-safe code (types/schemas) | Anywhere |
.server.ts | Server-only code (DB, secrets) | Only inside server function handlers |
.functions.ts | createServerFn wrappers | Anywhere (build creates RPC stubs for client) |
// users.functions.ts
import { createServerFn } from '@tanstack/react-start';
import { findUserById } from './users.server';
export const getUser = createServerFn({ method: 'GET' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
return findUserById(data.id);
});// users.server.ts
import { db } from '@/lib/db.server';
export async function findUserById(id: string) {
return db.users.findUnique({ where: { id } });
}Static imports of .functions.ts files are safe in client components — the build replaces server function implementations with RPC stubs.
Import Protection Pitfalls
Loaders Run on Both Server AND Client
Route loaders execute on the server during SSR and on the client during client-side navigation. Never access process.env directly in a loader — it leaks secrets to the client bundle:
// ❌ Loader runs on BOTH server and client — secret exposed
export const Route = createFileRoute('/users')({
loader: () => {
const secret = process.env.SECRET; // Bundled into client code
return fetch(`/api/users?key=${secret}`);
},
});
// ✅ Use a server function — secret stays on server
const getUsers = createServerFn().handler(async () => {
const secret = process.env.SECRET;
return fetch(`/api/users?key=${secret}`);
});
export const Route = createFileRoute('/users')({
loader: () => getUsers(),
});Direct process.env Access Outside Server Functions
Any process.env reference outside of createServerFn or createServerOnlyFn risks client exposure:
// ❌ Top-level — bundled into client
const apiKey = process.env.SECRET_KEY;
// ✅ Wrapped in server-only function
const apiKey = createServerOnlyFn(() => process.env.SECRET_KEY);Safe Import Patterns
| Import Source | Safe in Client? | Why |
|---|---|---|
.functions.ts | Yes | Build creates RPC stubs |
.server.ts | No | Contains raw server code |
.ts (types/schemas) | Yes | No server-only code |
createServerFn return | Yes | Serialized RPC call |
createServerOnlyFn return | No | Throws on client |
Environment Variables
VITE_ prefixed variables are inlined into the client bundle at build time. Non-prefixed variables are server-only — accessing them via import.meta.env on the client returns undefined (security feature).
# .env
# Server-only (no prefix) — never sent to browser
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=super-secret-key
STRIPE_SECRET_KEY=sk_live_...
# Client-safe (VITE_ prefix) — inlined into client bundle
VITE_APP_NAME=My App
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://...Access Patterns
| Context | Access | Available Variables |
|---|---|---|
| Server function | process.env.VAR | All variables |
| Client component | import.meta.env.VITE_VAR | Only VITE_ prefixed |
| Loader (runs both) | Neither directly | Use createServerFn for secrets |
// Server function — can access any variable
const getUser = createServerFn().handler(async () => {
const db = await connect(process.env.DATABASE_URL);
return db.user.findFirst();
});
// Client component — only VITE_ prefixed variables
export function AppHeader() {
return <h1>{import.meta.env.VITE_APP_NAME}</h1>;
}
// Feature flags via VITE_ prefix
export function FeatureGated({ children }: { children: React.ReactNode }) {
if (import.meta.env.VITE_ENABLE_NEW_DASHBOARD !== 'true') return null;
return <>{children}</>;
}Build-Time Inlining
VITE_ variables must be available at build time. They are statically replaced during the build — not read at runtime:
# ❌ Variable not set during build — inlined as undefined
npm run build
# ✅ Variable available at build time
VITE_API_URL=https://api.example.com npm run buildRestart the dev server after changing .env files — Vite only reads them on startup.
Validated Environment (Zod)
Validate server-side variables at startup to fail fast on misconfiguration:
// src/lib/env.server.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
});
export const env = envSchema.parse(process.env);Use *.server.ts suffix for env files to prevent accidental client import:
// src/lib/db.server.ts — only importable in server context
import { env } from './env.server';
import { drizzle } from 'drizzle-orm/postgres-js';
export const db = drizzle(env.DATABASE_URL);TypeScript Declarations
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
readonly VITE_API_URL: string;
readonly VITE_SENTRY_DSN?: string;
readonly VITE_ENABLE_NEW_DASHBOARD?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string;
readonly JWT_SECRET: string;
readonly NODE_ENV: 'development' | 'production' | 'test';
}
}
}
export {};Project Structure
Organize by feature for medium-to-large applications. Use @/* path aliases to avoid deep relative imports.
src/
├── features/
│ ├── auth/
│ │ ├── api/ # queries, mutations, server functions
│ │ ├── components/ # LoginForm.tsx, AuthGuard.tsx
│ │ ├── hooks/ # useAuth.ts
│ │ ├── types.ts
│ │ └── index.ts # barrel export (public API)
│ └── users/
├── shared/ # cross-feature components, hooks, utils
├── lib/ # api-client.ts, query-client.ts
└── config/ # env.ts, constants.tsNaming Conventions
| Category | Convention | Example |
|---|---|---|
| Components | PascalCase.tsx | UserCard.tsx |
| Hooks | camelCase.ts | useUserProfile.ts |
| Utils | kebab-case.ts | format-date.ts |
| Queries | feature.queries.ts | users.queries.ts |
| Routes | kebab-case.tsx | user-profile.tsx |
| Server code | *.server.ts | db.server.ts |
| Server fns | *.functions.ts | users.functions.ts |
End-to-End Flows
Form Submission Flow (Form -> Server -> Query Cache)
Complete pattern: user fills form, submits via mutation, cache updates, UI reflects new data.
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(1),
body: z.string().min(10),
categoryId: z.string(),
});
type CreatePostInput = z.infer<typeof postSchema>;
function CreatePostPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const createMutation = useMutation({
mutationFn: (data: CreatePostInput) =>
fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then((res) => res.json()),
onSuccess: (newPost) => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.setQueryData(['posts', newPost.id], newPost);
navigate({ to: '/posts/$postId', params: { postId: newPost.id } });
},
});
const form = useForm({
defaultValues: {
title: '',
body: '',
categoryId: '',
} satisfies CreatePostInput,
validatorAdapter: zodValidator(),
validators: { onChange: postSchema },
onSubmit: async ({ value }) => {
await createMutation.mutateAsync(value);
},
});
// Render form with form.Field for each input,
// form.Subscribe for canSubmit/isSubmitting state,
// and createMutation.isError for server error display.
// See form-query-integration reference for full field patterns.
}Infinite List with Intersection Observer
Paginated data with automatic loading on scroll using useInfiniteQuery and the Intersection Observer API.
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useEffect, useCallback } from 'react';
interface Page<T> {
items: T[];
nextCursor: string | null;
}
function fetchPosts(cursor?: string): Promise<Page<Post>> {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.set('cursor', cursor);
return fetch(`/api/posts?${params}`).then((res) => res.json());
}
function InfinitePostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = observerTarget.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' },
);
observer.observe(element);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (status === 'pending') return <PostListSkeleton />;
if (status === 'error') return <ErrorMessage />;
const allPosts = data.pages.flatMap((page) => page.items);
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div ref={observerTarget} aria-hidden="true" />
{isFetchingNextPage && <LoadingSpinner />}
</div>
);
}Auth-Protected Route Flow
Authentication check in the root loader gates access to protected routes.
Auth Query Options
import { queryOptions } from '@tanstack/react-query';
export const authQueryOptions = queryOptions({
queryKey: ['auth', 'session'],
queryFn: async () => {
const res = await fetch('/api/auth/session');
if (!res.ok) return null;
return res.json() as Promise<{ id: string; role: string }>;
},
staleTime: 5 * 60 * 1000,
retry: false,
});Root Route with Auth Context
import {
createRootRouteWithContext,
Outlet,
redirect,
} from '@tanstack/react-router';
import { type QueryClient } from '@tanstack/react-query';
interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ context: { queryClient } }) => {
const user = await queryClient.ensureQueryData(authQueryOptions);
return { user };
},
component: () => <Outlet />,
});Protected Layout Route
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router';
export const Route = createFileRoute('/_protected')({
beforeLoad: async ({ context }) => {
if (!context.user) {
throw redirect({ to: '/login', search: { redirect: location.pathname } });
}
},
component: () => (
<div className="authenticated-layout">
<Sidebar />
<main>
<Outlet />
</main>
</div>
),
});Protected child routes access the user via Route.useRouteContext() and use the standard loader + useSuspenseQuery pattern for data.
Login Page with Redirect
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
const loginSearchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute('/login')({
validateSearch: loginSearchSchema,
component: LoginPage,
});
function LoginPage() {
const { redirect: redirectTo } = Route.useSearch();
const navigate = useNavigate();
const queryClient = useQueryClient();
const loginMutation = useMutation({
mutationFn: (credentials: { email: string; password: string }) =>
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
}).then((res) => {
if (!res.ok) throw new Error('Invalid credentials');
return res.json();
}),
onSuccess: (user) => {
queryClient.setQueryData(['auth', 'session'], user);
navigate({ to: redirectTo ?? '/dashboard' });
},
});
// Form renders inputs, error display, and submit button
// using loginMutation.mutate(), .isError, .isPending
}Paginated Table with URL State
Server-side pagination with search params as the source of truth for table state.
Route with Validated Search Params
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { useSuspenseQuery } from '@tanstack/react-query';
import { z } from 'zod';
const searchSchema = z.object({
page: z.number().default(1),
size: z.number().default(10),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
search: z.string().optional(),
});
type SearchParams = z.infer<typeof searchSchema>;
export const Route = createFileRoute('/_protected/admin/users')({
validateSearch: zodValidator(searchSchema),
loaderDeps: ({ search }) => search,
loader: async ({ context: { queryClient }, deps }) => {
await queryClient.ensureQueryData(userQueries.list(deps));
},
component: UsersPage,
});Component with URL-Synced Table State
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const { data } = useSuspenseQuery(userQueries.list(search));
const handlePaginationChange = (pagination: {
pageIndex: number;
pageSize: number;
}) => {
navigate({
search: (prev: SearchParams) => ({
...prev,
page: pagination.pageIndex + 1,
size: pagination.pageSize,
}),
});
};
const handleSearchChange = (value: string) => {
navigate({
search: (prev: SearchParams) => ({
...prev,
search: value || undefined,
page: 1,
}),
});
};
return (
<div>
<input
defaultValue={search.search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search users..."
/>
<UserTable
data={data.items}
pageCount={data.meta.totalPages}
pagination={{
pageIndex: search.page - 1,
pageSize: search.size,
}}
onPaginationChange={handlePaginationChange}
/>
</div>
);
}Key pattern: reset page to 1 when filters change. The URL is the single source of truth for table state -- back/forward navigation restores exact table position.
Error Handling Flow
Structured errors with route-level boundaries and server function error patterns.
Route Error Components
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_protected/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
await queryClient.ensureQueryData(postQueries.detail(params.postId));
},
component: PostPage,
errorComponent: ({ error, reset }) => (
<div>
<p>{error.message}</p>
<button onClick={reset}>Try Again</button>
</div>
),
notFoundComponent: () => <p>Post not found</p>,
});Server Function Error Handling
const mutation = useMutation({
mutationFn: (values: CreatePostInput) => createPost({ data: values }),
onSuccess: (result) => {
if ('error' in result) {
switch (result.code) {
case 'AUTH_REQUIRED':
navigate({ to: '/login' });
break;
case 'VALIDATION_ERROR':
form.setErrorMap({ onServer: result.error });
break;
default:
toast.error(result.error);
}
return;
}
queryClient.invalidateQueries({ queryKey: ['posts'] });
navigate({ to: '/posts' });
},
});Server functions return structured { error, code } objects rather than throwing. Always check the result shape in onSuccess instead of relying on onError.
Flow Summary
| Flow | Key Libraries | Pattern |
|---|---|---|
| Form submission | Form + Query + Router | useForm -> useMutation -> invalidateQueries -> navigate |
| Infinite scroll | Query + Intersection Observer | useInfiniteQuery -> observer triggers fetchNextPage |
| Paginated table | Table + Query + Router | validateSearch -> loaderDeps -> navigate on state change |
| Auth protection | Router + Query | beforeLoad checks session -> redirect if unauthenticated |
| Error handling | Router + Server Functions | errorComponent on routes, structured errors from server |
Known Issues
Issue 1: Middleware Does Not Catch Server Function Errors
Error: Errors thrown by server functions bypass middleware try-catch blocks. Status: Fixed in v1.155+. Workaround (v1.154 and earlier):
const middleware = createMiddleware().server(async (ctx) => {
try {
const r = await ctx.next();
if ('error' in r && r.error) throw r.error;
return r;
} catch (error) {
console.error('Middleware caught an error:', error);
return new Response('An error occurred', { status: 500 });
}
});Issue 2: File Upload Streaming Not Supported
Error: Large file uploads consume excessive memory. Cause: Framework calls await request.formData() before handler runs. Status: Open (#5704). Workarounds: Client-side file size validation; use Cloudflare R2 multipart upload API directly for large files.
Issue 3: Server Function Redirects Return Undefined
Error: Type errors when using server function result after redirect. Status: Open PR (#6295). Prevention: Always check return value before use.
const result = await login({ username, password });
if (result) {
console.log(result.name);
}Issue 4: Stateful Auth Cookies Not Forwarded
Error: 401 Unauthorized when calling stateful backend APIs from server functions. Cause: Server functions originate from Start server, not browser. Solutions: Use createIsomorphicFn for read operations, or manually forward headers:
const headers = getRequestHeaders();
const response = await fetch('https://api.example.com/user', {
headers: {
Cookie: headers.get('cookie') || '',
'X-XSRF-TOKEN': headers.get('x-xsrf-token') || '',
Origin: headers.get('origin') || '',
},
});Issue 5: Prisma Edge Module Not Found
Error: "No such module 'assets/.prisma/client/edge'". Fix: Configure Prisma with runtime = "cloudflare" in schema.prisma.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
engineType = "library"
runtime = "cloudflare"
}Issue 6: Better Auth Cookie Caching Issues
Error: Session cookies not set/refreshed properly. Fix: Use reactStartCookies() plugin.
import { betterAuth } from 'better-auth';
import { reactStartCookies } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [reactStartCookies()],
});Issue 7: Missing nodejs_compat Flag
Error: Runtime errors when using Node.js APIs on Cloudflare Workers. Fix: Add compatibility_flags = ["nodejs_compat"] to wrangler.toml.
Issue 8: Prerendering Fails with Cloudflare Bindings
Error: Build fails when routes with loaders use D1/KV/R2. Fix: Set prerender: false on routes with bindings, or add conditional logic:
loader: async ({ context }) => {
if (typeof context.cloudflare === 'undefined') {
return { users: [] };
}
return {
users: await context.cloudflare.env.DB.prepare('SELECT * FROM users').all(),
};
};Issue 9: Migration History
TanStack Start has undergone two major architectural migrations:
Vinxi → Vite (v1.121.0)
Error: "invariant failed: could not find the nearest match".
| Old (Vinxi) | New (Vite) |
|---|---|
@tanstack/start | @tanstack/react-start |
app.config.ts | vite.config.ts |
app/ source folder | src/ |
vinxi dev | vite dev |
Server Routes Overhaul
| Old | Current |
|---|---|
createAPIFileRoute() (Vinxi) | createFileRoute() with server.handlers |
createServerFileRoute().methods() (Vite) | createFileRoute() with server.handlers |
server: { preset: '...' } (deployment) | Vite plugins (nitro/vite, @cloudflare/vite-plugin) |
setHeaders() import (ISR) | headers property on route definition |
Fix: Update all imports, config files, and API route patterns. Delete node_modules/ and reinstall if dependency conflicts persist.
Issue 10: Dev Server Slow with Many Routes
Cause: routeTree.gen.ts statically imports every route. 100+ routes generate 700+ HTTP requests in Vite dev mode. Status: Expected behavior until Router v2. Workarounds: Use production builds for testing; use Cloudflare Tunnel instead of ngrok (avoids rate limits).
What is LLMO
LLM Optimization (LLMO) — also called AI Optimization (AIO) or Generative Engine Optimization (GEO) — structures content so AI systems (ChatGPT, Claude, Perplexity) can accurately understand, cite, and recommend it.
LLMO vs SEO
| Aspect | SEO | LLMO |
|---|---|---|
| Goal | Rank in search results | Be cited/recommended by AI |
| Audience | Search engine crawlers | LLM training and retrieval systems |
| Key signals | Links, keywords, page speed | Structured data, clarity, authority |
| Content format | Optimized for snippets | Optimized for extraction and synthesis |
Many LLMO best practices overlap with SEO. Clear structure, authoritative content, and good metadata help both.
TanStack Start LLMO Features
- SSR — AI crawlers see fully rendered content
- JSON-LD via `head.scripts` — Machine-readable structured data on every route
- Server routes — Create machine-readable endpoints (APIs, feeds,
llms.txt) - Head management — Meta tags that AI systems parse for context
Structured Data (JSON-LD)
Use head.scripts with type: 'application/ld+json' to embed schema.org data.
Article Schema
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
return { post };
},
head: ({ loaderData }) => ({
meta: [{ title: loaderData.post.title }],
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: loaderData.post.title,
description: loaderData.post.excerpt,
image: loaderData.post.coverImage,
author: {
'@type': 'Person',
name: loaderData.post.author.name,
url: loaderData.post.author.url,
},
publisher: {
'@type': 'Organization',
name: 'My Company',
logo: {
'@type': 'ImageObject',
url: 'https://myapp.com/logo.png',
},
},
datePublished: loaderData.post.publishedAt,
dateModified: loaderData.post.updatedAt,
}),
},
],
}),
component: PostPage,
});Product Schema
export const Route = createFileRoute('/products/$productId')({
loader: async ({ params }) => {
const product = await fetchProduct(params.productId);
return { product };
},
head: ({ loaderData }) => ({
meta: [{ title: loaderData.product.name }],
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: loaderData.product.name,
description: loaderData.product.description,
image: loaderData.product.images,
brand: {
'@type': 'Brand',
name: loaderData.product.brand,
},
offers: {
'@type': 'Offer',
price: loaderData.product.price,
priceCurrency: 'USD',
availability: loaderData.product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
aggregateRating: loaderData.product.rating
? {
'@type': 'AggregateRating',
ratingValue: loaderData.product.rating,
reviewCount: loaderData.product.reviewCount,
}
: undefined,
}),
},
],
}),
component: ProductPage,
});Organization and Website Schema
Add to the root route for site-wide context:
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'My App',
url: 'https://myapp.com',
publisher: {
'@type': 'Organization',
name: 'My Company',
url: 'https://myapp.com',
logo: 'https://myapp.com/logo.png',
sameAs: [
'https://twitter.com/mycompany',
'https://github.com/mycompany',
],
},
}),
},
],
}),
component: RootComponent,
});FAQ Schema
Particularly effective for LLMO — AI systems often extract Q&A pairs:
export const Route = createFileRoute('/faq')({
loader: async () => {
const faqs = await fetchFAQs();
return { faqs };
},
head: ({ loaderData }) => ({
meta: [{ title: 'Frequently Asked Questions' }],
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: loaderData.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}),
},
],
}),
component: FAQPage,
});Common Schema Types
| Schema Type | Use Case | Key Properties |
|---|---|---|
Article | Blog posts, news | headline, author, datePublished |
Product | E-commerce items | name, offers, aggregateRating |
FAQPage | Q&A content | mainEntity (Question + Answer) |
WebSite | Root route, site-wide | name, url, publisher |
Organization | Company info | name, logo, sameAs |
HowTo | Tutorials, guides | step, tool, supply |
SoftwareApplication | App listings | applicationCategory, offers |
BreadcrumbList | Navigation path | itemListElement |
Machine-Readable Endpoints
Create API endpoints that AI systems and developers consume directly:
export const Route = createFileRoute('/api/products')({
server: {
handlers: {
GET: async ({ request }) => {
const url = new URL(request.url);
const category = url.searchParams.get('category');
const products = await fetchProducts({ category });
return Response.json({
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: products.map((product, index) => ({
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'Product',
name: product.name,
description: product.description,
url: `https://myapp.com/products/${product.id}`,
},
})),
});
},
},
},
});llms.txt
A llms.txt file (similar to robots.txt) provides guidance to AI systems about your site:
export const Route = createFileRoute('/llms.txt' as any)({
server: {
handlers: {
GET: async () => {
const content = `# My App
> My App is a platform for building modern web applications.
## Documentation
- Getting Started: https://myapp.com/docs/getting-started
- API Reference: https://myapp.com/docs/api
## Key Facts
- Built with TanStack Start
- Full TypeScript support
- SSR and streaming out of the box
## Contact
- Website: https://myapp.com
- GitHub: https://github.com/mycompany/myapp
`;
return new Response(content, {
headers: { 'Content-Type': 'text/plain' },
});
},
},
},
});Content Best Practices
Clear, Factual Statements
AI systems extract factual claims. Make key information explicit:
function ProductDetails({ product }: { product: Product }) {
return (
<article>
<h1>{product.name}</h1>
<p>
{product.name} is a {product.category} made by {product.brand}. It costs
${product.price} and is available in {product.colors.join(', ')}.
</p>
</article>
);
}Hierarchical Heading Structure
AI systems use heading hierarchy to understand content organization. Never skip heading levels.
Authoritative Attribution
Include author information and sources — AI systems consider authority signals:
export const Route = createFileRoute('/posts/$postId')({
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title },
{ name: 'author', content: loaderData.post.author.name },
{
property: 'article:author',
content: loaderData.post.author.profileUrl,
},
{
property: 'article:published_time',
content: loaderData.post.publishedAt,
},
],
}),
component: PostPage,
});Monitoring AI Citations
- Test with AI assistants — ask ChatGPT, Claude, and Perplexity about your product/content
- Monitor brand mentions — track how AI systems describe your offerings
- Validate structured data — use Google's Rich Results Test and Schema.org Validator
- Check AI search engines — monitor presence in Perplexity, Bing Chat, and Google AI Overviews
Local-First Integration
Server-Based vs Local-First Data Loading
TanStack Start traditionally loads data via server functions in route loaders. Local-first adds a second path where data syncs continuously from Postgres via ElectricSQL, with the client reading from a local collection instead of fetching on navigation.
| Aspect | Server-Based (Traditional) | Local-First (Electric + TanStack DB) |
|---|---|---|
| Read path | createServerFn → fetch → render | Electric shape → local collection → render |
| Write path | createServerFn({ method: 'POST' }) → API | collection.insert() → onInsert handler → API |
| Navigation | Loader runs on every route transition | Data already local, instant render |
| Offline | Fails without network | Reads work offline, writes queue |
| Initial load | Server renders with data | SSR for first paint, Electric syncs after hydrate |
| Cache strategy | TanStack Query staleTime / gcTime | Always fresh via continuous sync |
Shape Proxy with createServerFn
Never expose Electric directly to clients. Use a server function as a proxy that validates auth and injects per-user filtering:
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
import { z } from 'zod';
const shapeProxySchema = z.object({
table: z.string(),
offset: z.string().optional(),
handle: z.string().optional(),
live: z.enum(['true', 'false']).optional(),
cursor: z.string().optional(),
});
export const getShape = createServerFn()
.inputValidator(shapeProxySchema)
.handler(async ({ data }) => {
const authHeader = getRequestHeader('Authorization');
const session = await validateSession(authHeader);
if (!session) {
throw new Error('Unauthorized');
}
const params = new URLSearchParams({
table: data.table,
where: `user_id = '${session.userId}'`,
offset: data.offset ?? '-1',
});
if (data.handle) params.set('handle', data.handle);
if (data.live) params.set('live', data.live);
const electricUrl = process.env.ELECTRIC_URL ?? 'http://localhost:3000';
const secret = process.env.ELECTRIC_SECRET;
const response = await fetch(
`${electricUrl}/v1/shape?${params.toString()}`,
{
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
},
);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type':
response.headers.get('Content-Type') ?? 'application/json',
'electric-handle': response.headers.get('electric-handle') ?? '',
'electric-offset': response.headers.get('electric-offset') ?? '',
},
});
});API Route Shape Proxy
For shape streaming, API routes work better than server functions because they support long-polling and SSE natively:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/shapes/$table')({
server: {
handlers: {
GET: async ({ request, params }) => {
const url = new URL(request.url);
const session = await getSession(request);
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
const electricUrl = process.env.ELECTRIC_URL ?? 'http://localhost:3000';
const secret = process.env.ELECTRIC_SECRET;
const shapeParams = new URLSearchParams();
shapeParams.set('table', params.table);
shapeParams.set('where', `user_id = '${session.userId}'`);
for (const [key, value] of url.searchParams) {
if (
['offset', 'handle', 'live', 'live_sse', 'columns'].includes(key)
) {
shapeParams.set(key, value);
}
}
const response = await fetch(
`${electricUrl}/v1/shape?${shapeParams.toString()}`,
{
headers: {
...(secret && { Authorization: `Bearer ${secret}` }),
},
},
);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type':
response.headers.get('Content-Type') ?? 'application/json',
'Cache-Control': response.headers.get('Cache-Control') ?? '',
'electric-handle': response.headers.get('electric-handle') ?? '',
'electric-offset': response.headers.get('electric-offset') ?? '',
},
});
},
},
},
});Client-Side Collection with Proxy
Point the Electric collection at your proxy endpoint instead of Electric directly:
import { createCollection } from '@tanstack/react-db';
import { electricCollectionOptions } from '@tanstack/electric-db-collection';
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: '/api/shapes/todos',
},
onInsert: async ({ transaction }) => {
const newTodo = transaction.mutations[0].modified;
const result = await createTodo({ data: newTodo });
return { txid: result.txid };
},
}),
);Write-Path Server Functions
Pair the shape proxy (read path) with server functions for the write path:
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
const createTodoSchema = z.object({
title: z.string().min(1).max(200),
completed: z.boolean().default(false),
});
export const createTodo = createServerFn({ method: 'POST' })
.inputValidator(createTodoSchema)
.handler(async ({ data }) => {
const session = await getAuthSession();
if (!session) throw new Error('Unauthorized');
const todo = await db.todos.create({
data: { ...data, user_id: session.userId },
});
return { txid: todo.id, todo };
});
export const updateTodo = createServerFn({ method: 'POST' })
.inputValidator(
z.object({
id: z.string(),
changes: z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
}),
}),
)
.handler(async ({ data }) => {
const session = await getAuthSession();
if (!session) throw new Error('Unauthorized');
const todo = await db.todos.update({
where: { id: data.id, user_id: session.userId },
data: data.changes,
});
return { txid: todo.id };
});Mixing Server-Based and Local-First
Not every route needs local-first. Use server functions for admin pages, reports, and low-frequency data. Use Electric collections for collaborative, real-time, or offline-capable views.
import { createFileRoute } from '@tanstack/react-router';
import { useLiveQuery } from '@tanstack/react-db';
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const stats = await getDashboardStats();
return { stats };
},
component: Dashboard,
});
function Dashboard() {
const { stats } = Route.useLoaderData();
const { data: recentTodos } = useLiveQuery((q) =>
q
.from({ todos: todoCollection })
.orderBy(({ todos: t }) => t.created_at, 'desc')
.limit(10),
);
return (
<div>
<StatsPanel stats={stats} />
<RecentActivity todos={recentTodos} />
</div>
);
}In this pattern, stats loads server-side via the route loader (SSR-friendly, not real-time), while recentTodos syncs live via the Electric collection (real-time, works offline after initial sync).
SSR Considerations
Electric collections sync after hydration, so the first server render has no local data. Handle this with loading states or server-side prefetching:
function TodoList() {
const { data: todos, isLoading } = useLiveQuery((q) =>
q.from({ todos: todoCollection }),
);
if (isLoading) return <TodoListSkeleton />;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}For critical above-the-fold content, consider loading initial data via the route loader and transitioning to the live collection after hydration.
Environment Configuration
import { z } from 'zod';
const envSchema = z.object({
ELECTRIC_URL: z.string().url().default('http://localhost:3000'),
ELECTRIC_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);Place this in a .server.ts file so environment variables are validated at startup and never leak to the client bundle.
Deployment Checklist
| Item | Details |
|---|---|
| Electric service | Running alongside your app (Docker, cloud service, sidecar) |
ELECTRIC_URL | Internal URL from app to Electric (not exposed to clients) |
ELECTRIC_SECRET | Set in production, never ELECTRIC_INSECURE=true |
Postgres wal_level | Must be logical for Electric to connect |
| Shape proxy endpoint | API route or server function proxying to Electric |
| HTTPS termination | TLS at the load balancer, not at Electric |
| Cloudflare Workers | Electric proxy works, but long-poll may hit CPU limits |
| Node.js / Docker | Best fit for shape proxy with streaming support |
Middleware
Basic Middleware
import { createMiddleware } from '@tanstack/react-start';
import { getRequest } from '@tanstack/react-start/server';
export const logMiddleware = createMiddleware().server(async ({ next }) => {
const start = Date.now();
const requestId = crypto.randomUUID();
const request = getRequest();
console.log(`[${requestId}] ${request.method} ${request.url}`);
try {
const result = await next({ context: { requestId } });
console.log(`[${requestId}] Completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`[${requestId}] Error:`, error);
throw error;
}
});
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const session = await getSession();
return next({
context: { session, user: session?.user ?? null },
});
});Middleware Composition
Chain middleware using .middleware([dep]):
export const requireAuthMiddleware = createMiddleware()
.middleware([authMiddleware])
.server(async ({ next, context }) => {
if (!context.user) {
throw redirect({ to: '/login' });
}
return next({ context: { user: context.user } });
});
export const adminMiddleware = createMiddleware()
.middleware([requireAuthMiddleware])
.server(async ({ next, context }) => {
if (context.user.role !== 'admin') {
throw new Error('Admin access required');
}
return next({ context: { isAdmin: true } });
});Use in server functions:
const adminAction = createServerFn({ method: 'POST' })
.middleware([adminMiddleware])
.handler(async ({ context }) => {
return await performAdminAction(context.user.id);
});Function-Level Middleware with Validation
Use type: 'function' for middleware that validates input specific to server functions:
const workspaceMiddleware = createMiddleware({ type: 'function' })
.inputValidator(z.object({ workspaceId: z.string() }))
.server(async ({ next, data }) => {
const workspace = await db.workspaces.findUnique({
where: { id: data.workspaceId },
});
if (!workspace) throw new Error('Workspace not found');
return next({ context: { workspace } });
});
const getWorkspaceData = createServerFn()
.middleware([authMiddleware, workspaceMiddleware])
.handler(async ({ context }) => {
return await fetchWorkspaceData(context.workspace.id);
});Route-Level Middleware
Apply middleware to server route handlers:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/posts')({
server: {
middleware: [authMiddleware, logMiddleware],
handlers: {
GET: async ({ request }) => {
return Response.json(await db.posts.findMany());
},
POST: {
middleware: [validationMiddleware],
handler: async ({ request }) => {
const body = await request.json();
return Response.json(await db.posts.create({ data: body }));
},
},
},
},
});Route-level middleware applies to all handlers. Per-handler middleware runs after route-level middleware, only for that method.
Global Middleware
Apply middleware to all server functions in src/start.ts:
import { createStart } from '@tanstack/react-start';
export const startInstance = createStart(() => ({
requestMiddleware: [logMiddleware, authMiddleware],
functionMiddleware: [],
}));requestMiddleware runs on every server request (SSR, server functions, and API routes). functionMiddleware runs only on server function calls — use it for input validation or function-specific concerns.
Rate Limiting Middleware
import { getRequestHeader } from '@tanstack/react-start/server';
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export const rateLimitMiddleware = createMiddleware().server(
async ({ next }) => {
const ip = getRequestHeader('x-forwarded-for') ?? 'unknown';
const now = Date.now();
const windowMs = 60 * 1000;
const maxRequests = 100;
let record = rateLimitStore.get(ip);
if (!record || record.resetAt < now) {
record = { count: 0, resetAt: now + windowMs };
}
record.count++;
rateLimitStore.set(ip, record);
if (record.count > maxRequests) {
throw new Response('Too Many Requests', { status: 429 });
}
return next();
},
);Middleware Execution Order
Middleware forms a chain where each wraps the next:
Request -> Middleware 1 -> Middleware 2 -> Handler -> Middleware 2 -> Middleware 1 -> ResponseThe first middleware in the array wraps the entire chain. Each middleware calls next() to proceed to the next middleware or the handler. Code before await next() runs on the way in, code after runs on the way out.
Query Integration
Root Route with Context
// routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});Router with SSR Integration
// router.tsx
import { QueryClient } from '@tanstack/react-query';
import { createRouter } from '@tanstack/react-router';
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
import { routeTree } from './routeTree.gen';
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2,
refetchOnWindowFocus: false,
},
},
});
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0, // Query manages caching
scrollRestoration: true,
defaultStructuralSharing: true,
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>;
}
}SSR Data Flow
Server:
1. getRouter() creates fresh QueryClient + Router per request
2. setupRouterSsrQueryIntegration connects them
3. Router matches routes, runs loaders
4. Loaders call ensureQueryData -> data cached
5. Integration auto-dehydrates QueryClient state
6. HTML + serialized state streamed to client
Client:
1. getRouter() creates fresh QueryClient + Router
2. Integration auto-hydrates state from server
3. useSuspenseQuery finds data in cache - no refetch
4. App is interactive with data already loadedsetupRouterSsrQueryIntegration Options
| Option | Type | Default | Description |
|---|---|---|---|
router | Router | Required | Router instance |
queryClient | QueryClient | Required | QueryClient instance |
handleRedirects | boolean | true | Intercept redirects from queries/mutations |
wrapQueryClient | boolean | true | Auto-wrap with QueryClientProvider |
Custom Provider with DevTools
When you need DevTools, disable automatic wrapping and provide your own:
export function getRouter() {
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
});
setupRouterSsrQueryIntegration({
router,
queryClient,
wrapQueryClient: false,
});
router.options.Wrap = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
return router;
}Root Route with Additional Context
interface RouterContext {
queryClient: QueryClient;
user: User | null;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(authQueryOptions);
},
});Testing with Mock QueryClient
function renderWithProviders(route: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const router = createRouter({
routeTree,
context: { queryClient },
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
return {
...render(<RouterProvider router={router} />),
queryClient,
};
}Vite Configuration for TanStack Start
// vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [tanstackStart(), react()],
});TanStack Start handles client hydration and SSR automatically via the Vite plugin. No separate entry files needed.
Route Protection
beforeLoad for Auth Checks
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
const session = await getSessionData();
if (!session) {
throw redirect({
to: '/login',
search: { redirect: location.href },
});
}
return { user: session };
},
component: AuthenticatedLayout,
});Child routes automatically inherit protection and context.user:
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: async ({ context }) => {
return await fetchDashboardData(context.user.id);
},
});Role-Based Access
export const Route = createFileRoute('/_authenticated/_admin')({
beforeLoad: async ({ context }) => {
if (context.user.role !== 'admin') {
throw redirect({ to: '/unauthorized' });
}
},
component: AdminLayout,
});File structure for nested protection:
routes/
_authenticated.tsx # Requires login
_authenticated/
dashboard.tsx # /dashboard - any authenticated user
settings.tsx # /settings - any authenticated user
_admin.tsx # Admin layout
_admin/
users.tsx # /users - admin only
analytics.tsx # /analytics - admin onlySession Management
import { useSession } from '@tanstack/react-start/server';
export function getSession() {
return useSession({
password: process.env.SESSION_SECRET!,
cookie: {
name: '__session',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
},
});
}Session Security Checklist
| Setting | Value | Purpose |
|---|---|---|
httpOnly | true | Prevents XSS from accessing cookie |
secure | true in prod | Requires HTTPS |
sameSite | 'lax' or 'strict' | CSRF protection |
maxAge | Application-specific | Session duration |
password | 32+ random chars | Encryption key |
Generate a secure session secret: openssl rand -base64 32
Session Validation and Expiry
interface SessionData {
userId: string;
email: string;
role: 'user' | 'admin';
createdAt: number;
}
export async function getValidatedSession(): Promise<SessionData | null> {
const session = await getSession();
const data = session.data as SessionData | undefined;
if (!data?.userId) {
return null;
}
const maxAge = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - data.createdAt > maxAge) {
await session.clear();
return null;
}
return data;
}Session Refresh
export const refreshSession = createServerFn({ method: 'POST' }).handler(
async () => {
const session = await getSession();
const currentData = session.data as SessionData;
if (!currentData?.userId) {
throw redirect({ to: '/login' });
}
const user = await db.users.findUnique({
where: { id: currentData.userId },
select: { id: true, email: true, role: true },
});
if (!user) {
await session.clear();
throw redirect({ to: '/login' });
}
await session.update({
...currentData,
createdAt: Date.now(),
});
return { success: true };
},
);Session Cleanup
export const logout = createServerFn({ method: 'POST' }).handler(async () => {
const session = await getSession();
await session.clear();
throw redirect({ to: '/login' });
});
export const logoutAllDevices = createServerFn({ method: 'POST' }).handler(
async () => {
const session = await getSession();
const data = session.data as SessionData;
if (data?.userId) {
await db.sessions.deleteMany({
where: { userId: data.userId },
});
}
await session.clear();
throw redirect({ to: '/login' });
},
);Login and Logout Server Functions
import { createServerFn } from '@tanstack/react-start';
import { redirect } from '@tanstack/react-router';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(100),
});
export const loginFn = createServerFn({ method: 'POST' })
.inputValidator(loginSchema)
.handler(async ({ data }) => {
const user = await db.users.findUnique({ where: { email: data.email } });
if (!user || !(await verifyPassword(data.password, user.passwordHash))) {
return { error: 'Invalid credentials', code: 'AUTH_FAILED' };
}
const session = await getSession();
await session.update({ userId: user.id });
throw redirect({ to: '/dashboard' });
});
export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
const session = await getSession();
await session.update({ userId: undefined });
throw redirect({ to: '/login' });
});Reading Session in Loaders
export const getSessionData = createServerFn().handler(async () => {
const session = await getSession();
if (!session.data.userId) {
return null;
}
const user = await db.users.findUnique({
where: { id: session.data.userId },
select: { id: true, email: true, name: true, role: true },
});
return user;
});Use in the root route to make session data available app-wide:
export const Route = createRootRouteWithContext()({
beforeLoad: async () => {
const user = await getSessionData();
return { user };
},
});Preserving Redirect URL
import { z } from 'zod';
export const Route = createFileRoute('/login')({
validateSearch: z.object({
redirect: z.string().optional(),
}),
component: LoginPage,
});
function LoginPage() {
const { redirect: redirectTo } = Route.useSearch();
const loginMutation = useMutation({
mutationFn: loginFn,
onSuccess: () => {
navigate({ to: redirectTo ?? '/dashboard' });
},
});
return <LoginForm onSubmit={loginMutation.mutate} />;
}Forward Headers to External APIs
Server functions originate from the Start server, not the browser. Cookies and auth headers must be forwarded manually:
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
export const getExternalUser = createServerFn().handler(async () => {
const headers = getRequestHeaders();
const response = await fetch('https://api.example.com/me', {
headers: {
Cookie: headers.get('cookie') || '',
Authorization: headers.get('authorization') || '',
},
});
return response.json();
});createIsomorphicFn for Cookie Maintenance
Use createIsomorphicFn to avoid header forwarding for read operations. On the client, the browser attaches cookies automatically:
import { createIsomorphicFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
const fetchUser = createIsomorphicFn()
.client(async () => {
const res = await fetch('/api/user', { credentials: 'include' });
return res.json();
})
.server(async () => {
const headers = getRequestHeaders();
const res = await fetch('https://api.example.com/user', {
headers: { Cookie: headers.get('cookie') || '' },
});
return res.json();
});Better Auth Integration
Use the reactStartCookies() plugin to handle cookie synchronization:
import { betterAuth } from 'better-auth';
import { reactStartCookies } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [reactStartCookies()],
});Without this plugin, session cookies may not be set or refreshed properly in TanStack Start server functions.
Security Anti-Patterns
- Storing auth tokens in localStorage -- Use HTTP-only cookies
- Checking auth in component useEffect -- Use
beforeLoadon routes to prevent data loading for unauthenticated users - Exposing secrets via `process.env` in shared files -- Use
createServerOnlyFnfor secrets,VITE_prefix only for public config - Allowing client to set `role` / `isAdmin` -- Strip privileged fields in validation schema
- Returning the full user object from session -- Select only needed fields to avoid leaking sensitive data
SEO and Head Management
Root Route Head Configuration
Set global meta tags, favicons, and stylesheets on the root route using the head property:
import appCss from '@/styles/app.css?url';
export const Route = createRootRoute({
head: () => ({
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
{ name: 'description', content: 'A full-stack React application' },
],
links: [
{ rel: 'stylesheet', href: appCss },
{ rel: 'icon', href: '/favicon.ico' },
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
},
{ rel: 'manifest', href: '/site.webmanifest' },
],
}),
component: RootComponent,
});Per-Route Head
Override or extend head tags on individual routes:
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug);
if (!post) throw notFound();
return { post };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title },
{ name: 'description', content: loaderData.post.excerpt },
],
}),
});Open Graph and Twitter Cards
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug);
if (!post) throw notFound();
return { post };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title },
{ name: 'description', content: loaderData.post.excerpt },
{ property: 'og:title', content: loaderData.post.title },
{ property: 'og:description', content: loaderData.post.excerpt },
{ property: 'og:image', content: loaderData.post.coverImage },
{ property: 'og:type', content: 'article' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: loaderData.post.title },
{ name: 'twitter:description', content: loaderData.post.excerpt },
{ name: 'twitter:image', content: loaderData.post.coverImage },
],
}),
});SEO Helper Function
Create a reusable helper to reduce boilerplate:
type SeoOptions = {
title: string;
description: string;
image?: string;
type?: string;
};
export function seo({
title,
description,
image,
type = 'website',
}: SeoOptions) {
const tags: Array<Record<string, string>> = [
{ title },
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:type', content: type },
{
name: 'twitter:card',
content: image ? 'summary_large_image' : 'summary',
},
{ name: 'twitter:title', content: title },
{ name: 'twitter:description', content: description },
];
if (image) {
tags.push(
{ property: 'og:image', content: image },
{ name: 'twitter:image', content: image },
);
}
return tags;
}Usage:
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug);
if (!post) throw notFound();
return { post };
},
head: ({ loaderData }) => ({
meta: [
...seo({
title: loaderData.post.title,
description: loaderData.post.excerpt,
image: loaderData.post.coverImage,
type: 'article',
}),
],
}),
});Head Property Reference
| Property | Type | Description |
|---|---|---|
meta | Array<Record<string, string>> | Meta tags for the page |
links | Array<Record<string, string>> | Link elements (CSS, icons, manifest) |
Common meta tag patterns:
| Pattern | Description |
|---|---|
{ title: '...' } | Page title |
{ charset: 'utf-8' } | Character encoding |
{ name: 'viewport', ... } | Responsive viewport |
{ name: 'description', ... } | Page description |
{ property: 'og:...', ... } | Open Graph tags |
{ name: 'twitter:...', ... } | Twitter Card tags |
{ name: 'robots', ... } | Search engine directives |
Server Functions
Basic Server Function with Validation
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
});
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
const post = await db.posts.create({ data });
return post;
});
export const getPost = createServerFn()
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } });
if (!post) throw notFound();
return post;
});Use in Route Loaders
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return await getPost({ data: { id: params.postId } });
},
});Authenticated Server Functions
import { getRequestHeader } from '@tanstack/react-start/server';
const deletePost = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const authHeader = getRequestHeader('Authorization');
const session = await getSession(authHeader);
if (!session) {
return { error: 'Authentication required', code: 'AUTH_REQUIRED' };
}
const post = await db.posts.findUnique({ where: { id: data.id } });
if (post?.authorId !== session.user.id) {
return { error: 'Not authorized', code: 'FORBIDDEN' };
}
await db.posts.delete({ where: { id: data.id } });
return { success: true };
});Request Context
import { getRequest, getRequestHeader } from '@tanstack/react-start/server';
const serverFn = createServerFn({ method: 'POST' }).handler(async () => {
const request = getRequest();
const authHeader = getRequestHeader('Authorization');
const url = new URL(request.url);
});Response Headers and Cookies
import {
getRequestHeader,
setResponseHeaders,
} from '@tanstack/react-start/server';
const setTheme = createServerFn({ method: 'POST' })
.inputValidator(z.object({ theme: z.enum(['light', 'dark']) }))
.handler(async ({ data }) => {
setResponseHeaders(
new Headers({
'Set-Cookie': `theme=${data.theme}; Path=/; HttpOnly; SameSite=Lax`,
}),
);
return { success: true };
});
const getTheme = createServerFn().handler(async () => {
const cookies = getRequestHeader('cookie') ?? '';
const theme = cookies.match(/theme=(\w+)/)?.[1] || 'light';
return { theme };
});Calling from Components
function CreatePostForm() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const result = await createPost({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
});
if ('error' in result) {
toast.error(result.error);
} else {
toast.success('Post created');
}
};
return <form onSubmit={handleSubmit}>...</form>;
}useServerFn Hook
Wraps a server function for use in components with pending state tracking:
import { useServerFn } from '@tanstack/react-start';
function DeleteButton({ postId }: { postId: string }) {
const deletePostFn = useServerFn(deletePost);
const handleDelete = async () => {
const result = await deletePostFn({ data: { id: postId } });
if ('error' in result) {
toast.error(result.error);
}
};
return <button onClick={handleDelete}>Delete</button>;
}With TanStack Query
Use useServerFn with TanStack Query mutations for optimistic updates and cache invalidation:
import { useServerFn } from '@tanstack/react-start';
import {
queryOptions,
useSuspenseQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
const postsQuery = queryOptions({
queryKey: ['posts'],
queryFn: () => getPosts(),
});
function PostList() {
const { data: posts } = useSuspenseQuery(postsQuery);
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
function CreatePostButton() {
const queryClient = useQueryClient();
const createPostFn = useServerFn(createPost);
const mutation = useMutation({
mutationFn: createPostFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return (
<button
disabled={mutation.isPending}
onClick={() =>
mutation.mutate({ data: { title: 'New Post', content: '...' } })
}
>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
);
}File Uploads
import { getRequest } from '@tanstack/react-start/server';
const uploadFile = createServerFn({ method: 'POST' }).handler(async () => {
const request = getRequest();
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) throw new AppError('No file provided', 'VALIDATION_ERROR');
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize)
throw new AppError('File too large', 'VALIDATION_ERROR');
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
throw new AppError('Invalid file type', 'VALIDATION_ERROR');
}
const buffer = await file.arrayBuffer();
const filename = `${Date.now()}-${file.name}`;
await writeFile(`./uploads/${filename}`, Buffer.from(buffer));
return { filename, size: file.size };
});Streaming Responses
const streamData = createServerFn().handler(async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise((r) => setTimeout(r, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
});
});Validation Schema Composition
const baseUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const createUserSchema = baseUserSchema.extend({
password: z.string().min(8),
});
const updateUserSchema = baseUserSchema.partial();Async validation in handlers (for checks requiring DB access):
const registerUser = createServerFn({ method: 'POST' })
.inputValidator(createUserSchema)
.handler(async ({ data }) => {
const existing = await db.users.findUnique({
where: { email: data.email },
});
if (existing) {
return { error: 'Email already registered', code: 'CONFLICT' };
}
const user = await db.users.create({ data });
return { data: user };
});Composing Server Functions
export const getPostWithComments = createServerFn()
.inputValidator(z.object({ postId: z.string() }))
.handler(async ({ data }) => {
const [post, comments] = await Promise.all([
getPost({ data: { id: data.postId } }),
getComments({ data: { postId: data.postId } }),
]);
return { post, comments };
});Environment Functions
import {
createIsomorphicFn,
createServerOnlyFn,
createClientOnlyFn,
} from '@tanstack/react-start';
const getStorageValue = createIsomorphicFn()
.server(() => process.env.FEATURE_FLAG)
.client(() => localStorage.getItem('featureFlag'));
const readSecretConfig = createServerOnlyFn(() => {
return process.env.SECRET_API_KEY;
});
const getGeolocation = createClientOnlyFn(() => {
return navigator.geolocation.getCurrentPosition();
});Code inside .client() blocks is removed from server bundles and vice versa (tree shaking).
SSR and Streaming
Streaming SSR with Suspense
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(userQueries.profile());
queryClient.prefetchQuery(dashboardQueries.stats());
queryClient.prefetchQuery(activityQueries.recent());
},
component: DashboardPage,
});
function DashboardPage() {
const { data: user } = useSuspenseQuery(userQueries.profile());
return (
<div>
<Header user={user} />
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}ensureQueryData()-- blocks SSR until data is ready (above-the-fold content)prefetchQuery()-- starts fetch but streams when ready (below-the-fold content)
Error Boundaries with Streaming
Each streamed section can handle its own errors independently:
function DashboardPage() {
return (
<div>
<Header />
<ErrorBoundary fallback={<StatsError />}>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<ActivityError />}>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</ErrorBoundary>
</div>
);
}Progressive Enhancement
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
await queryClient.ensureQueryData(postQueries.detail(params.postId));
queryClient.prefetchQuery(commentQueries.forPost(params.postId));
queryClient.prefetchQuery(postQueries.related(params.postId));
},
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
return (
<article>
<PostHeader post={post} />
<PostContent content={post.content} />
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection postId={postId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedPosts postId={postId} />
</Suspense>
</article>
);
}Fallback Content Strategies
Design fallbacks that match the final content structure to minimize layout shift:
function CommentsSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-full mb-1" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
</div>
))}
</div>
);
}
function StatsCard({ stat }: { stat?: { label: string; value: number } }) {
if (!stat) {
return (
<div className="border rounded-lg p-6">
<div className="h-6 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-10 bg-gray-200 rounded w-3/4" />
</div>
);
}
return (
<div className="border rounded-lg p-6">
<h3 className="text-lg font-medium mb-2">{stat.label}</h3>
<p className="text-3xl font-bold">{stat.value.toLocaleString()}</p>
</div>
);
}Nested Suspense for Granular Streaming
function DashboardPage() {
const { data: user } = useSuspenseQuery(userQueries.profile());
return (
<div>
<Header user={user} />
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<StatsCard />}>
<StatsCard1 />
</Suspense>
<Suspense fallback={<StatsCard />}>
<StatsCard2 />
</Suspense>
<Suspense fallback={<StatsCard />}>
<StatsCard3 />
</Suspense>
</div>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}Static Prerendering
// vite.config.ts
export default defineConfig({
server: {
prerender: {
routes: ['/', '/about', '/contact', '/pricing'],
crawlLinks: true,
},
},
});Dynamic route generation:
export default defineConfig({
server: {
prerender: {
routes: async () => {
const posts = await db.posts.findMany({
where: { published: true },
select: { slug: true },
});
return ['/', '/blog', ...posts.map((p) => `/blog/${p.slug}`)];
},
},
},
});ISR with Cache-Control
Use the headers property on the route definition:
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug);
return { post };
},
headers: () => ({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
}),
});Cache-Control Directives
| Directive | Meaning |
|---|---|
s-maxage=N | CDN cache duration (seconds) |
max-age=N | Browser cache duration |
stale-while-revalidate=N | Serve stale while fetching fresh |
private | Don't cache on CDN (user-specific) |
no-store | Never cache |
Hybrid Static/Dynamic Strategy
// Static page (prerendered at build)
export const Route = createFileRoute('/products')({
loader: async () => {
const featured = await fetchFeaturedProducts();
return { featured };
},
});
// ISR page (cached 5 minutes)
export const Route = createFileRoute('/products/$productId')({
loader: async ({ params }) => {
const product = await fetchProduct(params.productId);
if (!product) throw notFound();
return { product };
},
headers: () => ({
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
}),
});
// Always SSR (user-specific)
export const Route = createFileRoute('/cart')({
loader: async ({ context }) => {
const cart = await fetchUserCart(context.user.id);
return { cart };
},
headers: () => ({
'Cache-Control': 'private, no-store',
}),
});Hydration Safety
Prevent mismatches by passing dynamic data from loaders:
export const Route = createFileRoute('/dashboard')({
loader: async () => ({ generatedAt: Date.now() }),
component: Dashboard,
});
function Dashboard() {
const { generatedAt } = Route.useLoaderData();
return <span>Generated at: {generatedAt}</span>;
}For client-only features, use lazy loading or useEffect:
import { lazy, Suspense } from 'react';
const ClientOnlyMap = lazy(() => import('./Map'));
function LocationPage() {
return (
<Suspense fallback={<MapPlaceholder />}>
<ClientOnlyMap />
</Suspense>
);
}| Mismatch Cause | Solution |
|---|---|
Date.now() / new Date() | Pass timestamp from loader |
Math.random() | Generate on server, pass to client |
window / document | Use useEffect or lazy loading |
| User timezone | Use UTC or client-only formatting |
| Browser-specific APIs | Check typeof window !== 'undefined' |
| Extension-injected content | Use suppressHydrationWarning |