
Tanstack Start Best Practices
- 8.6k installs
- 205 repo stars
- Updated April 3, 2026
- deckardger/tanstack-agent-skills
tanstack-start-best-practices is an agent skill that TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when build.
About
TanStack Start best practices for full-stack React applications Server functions middleware SSR authentication and deployment patterns Activate when building full-stack apps with TanStack Start name tanstack-start-best-practices description TanStack Start best practices for full-stack React applications Server functions middleware SSR authentication and deployment patterns Activate when building full-stack apps with TanStack Start TanStack Start Best Practices Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications These rules cover server functions middleware SSR authentication and deployment When to Apply Creating server functions for data mutations Setting up middleware for auth logging Configuring SSR and hydration Implementing authentication flows Handling errors across client server boundary Organizing full-stack code Deploying to various platforms Rule Categories by Priority Priority Category Rules Impact CRITICAL Server Functions 5 rules Core data mutation patterns CRITICAL Security 4 rules Prevents vulnerabilities HIGH Middleware 4 rules Request response handling HIGH Authentication 4 rules Secure user sessions MEDIUM API Routes.
- TanStack Start Best Practices
- Creating server functions for data mutations
- Setting up middleware for auth/logging
- Configuring SSR and hydration
- Implementing authentication flows
Tanstack Start Best Practices by the numbers
- 8,589 all-time installs (skills.sh)
- +114 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #79 of 2,203 Security skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
tanstack-start-best-practices capabilities & compatibility
- Capabilities
- tanstack start best practices · creating server functions for data mutations · setting up middleware for auth/logging · configuring ssr and hydration · implementing authentication flows
- Use cases
- documentation
What tanstack-start-best-practices says it does
--- name: tanstack-start-best-practices description: TanStack Start best practices for full-stack React applications.
Server functions, middleware, SSR, authentication, and deployment patterns.
Activate when building full-stack apps with TanStack Start.
--- # TanStack Start Best Practices Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications.
npx skills add https://github.com/deckardger/tanstack-agent-skills --skill tanstack-start-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8.6k |
|---|---|
| repo stars | ★ 205 |
| Security audit | 3 / 3 scanners passed |
| Last updated | April 3, 2026 |
| Repository | deckardger/tanstack-agent-skills ↗ |
What problem does tanstack-start-best-practices solve for developers using this skill?
TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
Who is it for?
Developers who need tanstack-start-best-practices patterns described in the cached skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
What you get
Actionable workflows and conventions from SKILL.md for tanstack-start-best-practices.
- server route handlers
- REST endpoint implementations
Files
TanStack Start Best Practices
Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment.
When to Apply
- Creating server functions for data mutations
- Setting up middleware for auth/logging
- Configuring SSR and hydration
- Implementing authentication flows
- Handling errors across client/server boundary
- Organizing full-stack code
- Deploying to various platforms
Rule Categories by Priority
| Priority | Category | Rules | Impact |
|---|---|---|---|
| CRITICAL | Server Functions | 5 rules | Core data mutation patterns |
| CRITICAL | Security | 4 rules | Prevents vulnerabilities |
| HIGH | Middleware | 4 rules | Request/response handling |
| HIGH | Authentication | 4 rules | Secure user sessions |
| MEDIUM | API Routes | 1 rule | External endpoint patterns |
| MEDIUM | SSR | 6 rules | Server rendering patterns |
| MEDIUM | Error Handling | 3 rules | Graceful failure handling |
| MEDIUM | Environment | 1 rule | Configuration management |
| LOW | File Organization | 3 rules | Maintainable code structure |
| LOW | Deployment | 2 rules | Production readiness |
Quick Reference
Server Functions (Prefix: sf-)
sf-create-server-fn— Use createServerFn for server-side logicsf-input-validation— Always validate server function inputssf-method-selection— Choose appropriate HTTP methodsf-error-handling— Handle errors in server functionssf-response-headers— Customize response headers when needed
Security (Prefix: sec-)
sec-validate-inputs— Validate all user inputs with schemassec-auth-middleware— Protect routes with auth middlewaresec-sensitive-data— Keep secrets server-side onlysec-csrf-protection— Implement CSRF protection for mutations
Middleware (Prefix: mw-)
mw-request-middleware— Use request middleware for cross-cutting concernsmw-function-middleware— Use function middleware for server functionsmw-context-flow— Properly pass context through middlewaremw-composability— Compose middleware effectively
Authentication (Prefix: auth-)
auth-session-management— Implement secure session handlingauth-route-protection— Protect routes with beforeLoadauth-server-functions— Verify auth in server functionsauth-cookie-security— Configure secure cookie settings
API Routes (Prefix: api-)
api-routes— Create API routes for external consumers
SSR (Prefix: ssr-)
ssr-data-loading— Load data appropriately for SSRssr-hydration-safety— Prevent hydration mismatchesssr-streaming— Implement streaming SSR for faster TTFBssr-selective— Apply selective SSR when beneficialssr-prerender— Configure static prerendering and ISR
Environment (Prefix: env-)
env-functions— Use environment functions for configuration
Error Handling (Prefix: err-)
err-server-errors— Handle server function errorserr-redirects— Use redirects appropriatelyerr-not-found— Handle not-found scenarios
File Organization (Prefix: file-)
file-separation— Separate server and client codefile-functions-file— Use .functions.ts patternfile-shared-validation— Share validation schemas
Deployment (Prefix: deploy-)
deploy-env-config— Configure environment variablesdeploy-adapters— Choose appropriate deployment adapter
How to Use
Each rule file in the rules/ directory contains: 1. Explanation — Why this pattern matters 2. Bad Example — Anti-pattern to avoid 3. Good Example — Recommended implementation 4. Context — When to apply or skip this rule
Full Reference
See individual rule files in rules/ directory for detailed guidance and code examples.
api-routes: Create Server Routes for External Consumers
Priority: MEDIUM
Explanation
While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility.
Bad Example
// Using server functions for webhook endpoints
export const stripeWebhook = createServerFn({ method: 'POST' })
.handler(async ({ request }) => {
// Server functions aren't designed for raw request handling
// No easy access to raw body for signature verification
// Response format is JSON by default
})
// Or exposing internal functions to external consumers
export const getUsers = createServerFn()
.handler(async () => {
return db.users.findMany()
})
// No versioning, no standard REST semanticsGood Example: Basic Server Route
// routes/api/users.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/users')({
server: {
handlers: {
GET: async ({ request }) => {
const users = await db.users.findMany({
select: { id: true, name: true, email: true },
})
return json(users, {
headers: {
'Cache-Control': 'public, max-age=60',
},
})
},
POST: async ({ request }) => {
const body = await request.json()
// Validate input
const parsed = createUserSchema.safeParse(body)
if (!parsed.success) {
return json({ error: parsed.error.flatten() }, { status: 400 })
}
const user = await db.users.create({ data: parsed.data })
return json(user, { status: 201 })
},
},
},
})Good Example: Webhook Handler
// routes/api/webhooks/stripe.ts
import { createFileRoute } from '@tanstack/react-router'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export const Route = createFileRoute('/api/webhooks/stripe')({
server: {
handlers: {
POST: async ({ request }) => {
const signature = request.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
// Get raw body for signature verification
const rawBody = await request.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return new Response('OK', { status: 200 })
},
},
},
})Good Example: RESTful Resource with Dynamic Params
// routes/api/posts/$postId.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/posts/$postId')({
server: {
handlers: {
GET: async ({ params }) => {
const post = await db.posts.findUnique({
where: { id: params.postId },
})
if (!post) {
return json({ error: 'Post not found' }, { status: 404 })
}
return json(post)
},
PUT: async ({ request, params }) => {
const body = await request.json()
const parsed = updatePostSchema.safeParse(body)
if (!parsed.success) {
return json({ error: parsed.error.flatten() }, { status: 400 })
}
const post = await db.posts.update({
where: { id: params.postId },
data: parsed.data,
})
return json(post)
},
DELETE: async ({ params }) => {
await db.posts.delete({ where: { id: params.postId } })
return new Response(null, { status: 204 })
},
},
},
})Good Example: With Route-Level Middleware
// routes/api/protected/data.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { apiKeyMiddleware } from '@/lib/middleware'
export const Route = createFileRoute('/api/protected/data')({
server: {
// Middleware applies to all handlers in this route
middleware: [apiKeyMiddleware],
handlers: {
GET: async ({ request, context }) => {
// context.client available from middleware
const data = await fetchDataForClient(context.client.id)
return json(data)
},
},
},
})Good Example: Using createHandlers for Handler-Specific Middleware
// routes/api/admin/users.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/admin/users')({
server: {
middleware: [authMiddleware], // All handlers require auth
handlers: (createHandlers) => ({
GET: createHandlers.GET(async ({ context }) => {
const users = await db.users.findMany()
return json(users)
}),
// DELETE requires additional admin middleware
DELETE: createHandlers.DELETE({
middleware: [adminOnlyMiddleware],
handler: async ({ request, context }) => {
const { userId } = await request.json()
await db.users.delete({ where: { id: userId } })
return json({ deleted: true })
},
}),
}),
},
})Server Functions vs Server Routes
| Feature | Server Functions | Server Routes |
|---|---|---|
| Primary use | Internal RPC | External consumers |
| Type safety | Full end-to-end | Manual |
| Response format | JSON (automatic) | Any (manual) |
| Raw request access | Limited | Full |
| URL structure | Auto-generated | Explicit paths |
| Webhooks | Not ideal | Designed for |
Context
- Server routes use
createFileRoutewith aserver.handlersproperty - Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
- Use
json()helper for JSON responses - Return
Responseobjects for custom formats - Handler receives
{ request, params }object - Ideal for: webhooks, public APIs, file downloads, third-party integrations
- Consider versioning:
/api/v1/usersfor public APIs
auth-route-protection: Protect Routes with beforeLoad
Priority: HIGH
Explanation
Use beforeLoad in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes.
Bad Example
// Checking auth in component - too late, data may have loaded
function DashboardPage() {
const user = useAuth()
useEffect(() => {
if (!user) {
navigate({ to: '/login' }) // Redirect after render
}
}, [user])
if (!user) return null // Flash of content possible
return <Dashboard user={user} />
}
// No protection on route
export const Route = createFileRoute('/dashboard')({
loader: async () => {
// Fetches sensitive data even for unauthenticated users
return await fetchDashboardData()
},
component: DashboardPage,
})Good Example: Route-Level Protection
// routes/_authenticated.tsx - Layout route for protected area
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { getSessionData } from '@/lib/session.server'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
const session = await getSessionData()
if (!session) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
})
}
// Extend context with user for all child routes
return {
user: session,
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
return (
<div>
<AuthenticatedNav />
<main>
<Outlet /> {/* Child routes render here */}
</main>
</div>
)
}
// routes/_authenticated/dashboard.tsx
// This route is automatically protected by parent
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: async ({ context }) => {
// context.user is guaranteed to exist
return await fetchDashboardData(context.user.id)
},
component: DashboardPage,
})
function DashboardPage() {
const data = Route.useLoaderData()
const { user } = Route.useRouteContext()
return <Dashboard data={data} user={user} />
}Good Example: Role-Based Access
// routes/_admin.tsx
export const Route = createFileRoute('/_admin')({
beforeLoad: async ({ context }) => {
// context.user comes from parent _authenticated route
if (context.user.role !== 'admin') {
throw redirect({ to: '/unauthorized' })
}
},
component: AdminLayout,
})
// File structure:
// 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 onlyGood Example: Preserving Redirect URL
// routes/login.tsx
import { z } from 'zod'
export const Route = createFileRoute('/login')({
validateSearch: z.object({
redirect: z.string().optional(),
}),
component: LoginPage,
})
function LoginPage() {
const { redirect } = Route.useSearch()
const loginMutation = useMutation({
mutationFn: login,
onSuccess: () => {
// Redirect to original destination or default
navigate({ to: redirect ?? '/dashboard' })
},
})
return <LoginForm onSubmit={loginMutation.mutate} />
}
// In protected routes
beforeLoad: async ({ location }) => {
if (!session) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
}Good Example: Conditional Content Based on Auth
// Public route with different content for logged-in users
export const Route = createFileRoute('/')({
beforeLoad: async () => {
const session = await getSessionData()
return { user: session?.user ?? null }
},
component: HomePage,
})
function HomePage() {
const { user } = Route.useRouteContext()
return (
<div>
<Hero />
{user ? (
<PersonalizedContent user={user} />
) : (
<SignUpCTA />
)}
</div>
)
}Context
beforeLoadruns before route loading begins- Throwing
redirect()prevents route from loading - Context from
beforeLoadflows to loader and component - Child routes inherit parent's
beforeLoadprotection - Use pathless layout routes (
_authenticated.tsx) for grouped protection - Store redirect URL in search params for post-login navigation
auth-session-management: Implement Secure Session Handling
Priority: HIGH
Explanation
Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage.
Bad Example
// Storing auth in localStorage - vulnerable to XSS
function login(credentials: Credentials) {
const token = await authenticate(credentials)
localStorage.setItem('authToken', token) // XSS can steal this
}
// Non-HTTP-only cookie - JavaScript accessible
export const setSession = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
setResponseHeader('Set-Cookie', `session=${data.token}`) // Not secure
})Good Example: Secure Session Cookie
// lib/session.server.ts
import { useSession } from '@tanstack/react-start/server'
// Configure session with secure defaults
export function getSession() {
return useSession({
password: process.env.SESSION_SECRET!, // At least 32 characters
cookie: {
name: '__session',
httpOnly: true, // Not accessible via JavaScript
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
},
})
}
// Usage in server function
export const login = createServerFn({ method: 'POST' })
.validator(loginSchema)
.handler(async ({ data }) => {
const session = await getSession()
// Verify credentials
const user = await verifyCredentials(data.email, data.password)
if (!user) {
throw new Error('Invalid credentials')
}
// Store only essential data in session
await session.update({
userId: user.id,
email: user.email,
createdAt: Date.now(),
})
return { success: true }
})Good Example: Full Authentication Flow
// lib/auth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { getSession } from './session.server'
import { hashPassword, verifyPassword } from './password.server'
// Login
export const login = createServerFn({ method: 'POST' })
.validator(z.object({
email: z.string().email(),
password: z.string().min(1),
}))
.handler(async ({ data }) => {
const user = await db.users.findUnique({
where: { email: data.email },
})
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
throw new Error('Invalid email or password')
}
const session = await getSession()
await session.update({
userId: user.id,
email: user.email,
})
throw redirect({ to: '/dashboard' })
})
// Logout
export const logout = createServerFn({ method: 'POST' })
.handler(async () => {
const session = await getSession()
await session.clear()
throw redirect({ to: '/' })
})
// Get current user
export const getCurrentUser = createServerFn()
.handler(async () => {
const session = await getSession()
const data = await session.data
if (!data?.userId) {
return null
}
const user = await db.users.findUnique({
where: { id: data.userId },
select: {
id: true,
email: true,
name: true,
avatar: true,
// Don't include passwordHash!
},
})
return user
})Good Example: Session with Role-Based Access
// lib/session.server.ts
interface SessionData {
userId: string
email: string
role: 'user' | 'admin'
createdAt: number
}
export async function getSessionData(): Promise<SessionData | null> {
const session = await getSession()
const data = await session.data
if (!data?.userId) return null
// Validate session age
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
if (Date.now() - data.createdAt > maxAge) {
await session.clear()
return null
}
return data as SessionData
}
// Middleware for admin-only routes
export const requireAdmin = createMiddleware()
.server(async ({ next }) => {
const session = await getSessionData()
if (!session || session.role !== 'admin') {
throw redirect({ to: '/unauthorized' })
}
return next({ context: { session } })
})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 |
Context
- Always use HTTP-only cookies for session tokens
- Generate
SESSION_SECRETwithopenssl rand -base64 32 - Store minimal data in session - fetch user details on demand
- Implement session rotation on privilege changes
- Consider session invalidation on password change
- Use
sameSite: 'strict'for highest CSRF protection
deploy-adapters: Choose Appropriate Deployment Adapter
Priority: LOW
Explanation
TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities.
Bad Example
// Not configuring adapter - using defaults may not match your host
// app.config.ts
export default defineConfig({
// No adapter specified
// May not work correctly on your deployment platform
})
// Or using wrong adapter for platform
export default defineConfig({
server: {
preset: 'node-server', // But deploying to Vercel Edge
},
})Good Example: Vercel Deployment
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'vercel',
// Vercel-specific options
},
})
// vercel.json (optional, for customization)
{
"framework": null,
"buildCommand": "npm run build",
"outputDirectory": ".output"
}Good Example: Cloudflare Pages
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'cloudflare-pages',
},
})
// wrangler.toml
name = "my-tanstack-app"
compatibility_date = "2024-01-01"
pages_build_output_dir = ".output/public"
// For Cloudflare Workers (full control)
export default defineConfig({
server: {
preset: 'cloudflare',
},
})Good Example: Netlify
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'netlify',
},
})
// netlify.toml
[build]
command = "npm run build"
publish = ".output/public"
[functions]
directory = ".output/server"Good Example: Node.js Server
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'node-server',
// Optional: customize port
},
})
// Dockerfile
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"]
// Or run directly
// node .output/server/index.mjsGood Example: Static Export (SPA)
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'static',
prerender: {
routes: ['/'],
crawlLinks: true,
},
},
})
// Output: .output/public (static files only)
// Host anywhere: GitHub Pages, S3, any static hostGood Example: AWS Lambda
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'aws-lambda',
},
})
// Deploy with SST, Serverless Framework, or AWS CDK
// serverless.yml example:
service: my-tanstack-app
provider:
name: aws
runtime: nodejs20.x
functions:
app:
handler: .output/server/index.handler
events:
- http: ANY /
- http: ANY /{proxy+}Good Example: Bun Runtime
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'bun',
},
})
// Run with: bun .output/server/index.mjsAdapter Comparison
| Adapter | Runtime | Edge | Static | Best For |
|---|---|---|---|---|
vercel | Node/Edge | Yes | Yes | Vercel hosting |
cloudflare-pages | Workers | Yes | Yes | Cloudflare Pages |
cloudflare | Workers | Yes | No | Cloudflare Workers |
netlify | Node | Yes | Yes | Netlify hosting |
node-server | Node | No | No | Docker, VPS, self-host |
static | None | No | Yes | Any static host |
aws-lambda | Node | No | No | AWS serverless |
bun | Bun | No | No | Bun runtime |
Context
- Adapters transform output for target platform
- Edge adapters have API limitations (no file system, etc.)
- Static preset requires all routes to be prerenderable
- Test locally with
npm run build && npm run preview - Check platform docs for runtime-specific constraints
- Some platforms auto-detect TanStack Start (no adapter needed)
env-functions: Use Environment Functions for Configuration
Priority: MEDIUM
Explanation
Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production).
Bad Example
// Accessing env vars directly - no validation, potential leaks
export const getApiData = createServerFn()
.handler(async () => {
// No validation - may be undefined
const apiKey = process.env.API_KEY
// Accidentally exposed in error messages
if (!apiKey) {
throw new Error(`Missing API_KEY: ${process.env}`)
}
return fetch(url, { headers: { Authorization: apiKey } })
})
// Or importing env in shared files
// lib/config.ts
export const config = {
apiKey: process.env.API_KEY, // Bundled into client!
dbUrl: process.env.DATABASE_URL,
}Good Example: Validated Environment Configuration
// lib/env.server.ts
import { z } from 'zod'
const envSchema = z.object({
// Required
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
// API Keys
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
// Optional with defaults
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Optional
SENTRY_DSN: z.string().url().optional(),
})
export type Env = z.infer<typeof envSchema>
function validateEnv(): Env {
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:')
console.error(parsed.error.flatten().fieldErrors)
throw new Error('Invalid environment configuration')
}
return parsed.data
}
// Validate once at startup
export const env = validateEnv()
// Usage in server functions
export const getPaymentIntent = createServerFn({ method: 'POST' })
.handler(async () => {
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
// Type-safe, validated access
})Good Example: Public vs Private Config
// lib/env.server.ts - Server only (secrets)
export const serverEnv = {
databaseUrl: process.env.DATABASE_URL!,
sessionSecret: process.env.SESSION_SECRET!,
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
}
// lib/env.ts - Public config (safe for client)
export const publicEnv = {
appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000',
stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
sentryDsn: process.env.VITE_SENTRY_DSN,
}
// Vite exposes VITE_ prefixed vars to client
// Non-prefixed vars are server-onlyGood Example: Environment-Specific Behavior
// lib/env.server.ts
export const env = validateEnv()
export const isDevelopment = env.NODE_ENV === 'development'
export const isProduction = env.NODE_ENV === 'production'
export const isStaging = env.NODE_ENV === 'staging'
// lib/logger.server.ts
import { env, isDevelopment } from './env.server'
export function log(level: string, message: string, data?: unknown) {
if (isDevelopment) {
console.log(`[${level}]`, message, data)
return
}
// Production: send to logging service
if (env.SENTRY_DSN) {
// Send to Sentry
}
}
// Server function with environment checks
export const debugInfo = createServerFn()
.handler(async () => {
if (isProduction) {
throw new Error('Debug endpoint not available in production')
}
return {
nodeVersion: process.version,
env: env.NODE_ENV,
}
})Good Example: Feature Flags via Environment
// lib/features.server.ts
import { env } from './env.server'
export const features = {
newCheckout: env.FEATURE_NEW_CHECKOUT === 'true',
betaDashboard: env.FEATURE_BETA_DASHBOARD === 'true',
aiAssistant: env.FEATURE_AI_ASSISTANT === 'true',
}
// Usage in server functions
export const getCheckoutUrl = createServerFn()
.handler(async () => {
if (features.newCheckout) {
return '/checkout/v2'
}
return '/checkout'
})
// Usage in loaders
export const Route = createFileRoute('/dashboard')({
loader: async () => {
return {
showBetaFeatures: features.betaDashboard,
}
},
})Good Example: Type-Safe env.d.ts
// env.d.ts - TypeScript declarations for env vars
declare namespace NodeJS {
interface ProcessEnv {
// Required
DATABASE_URL: string
SESSION_SECRET: string
// Optional
NODE_ENV?: 'development' | 'staging' | 'production'
SENTRY_DSN?: string
// Vite public vars
VITE_APP_URL?: string
VITE_STRIPE_PUBLIC_KEY: string
}
}Environment Variable Checklist
| Variable | Prefix | Accessible On |
|---|---|---|
DATABASE_URL | None | Server only |
SESSION_SECRET | None | Server only |
STRIPE_SECRET_KEY | None | Server only |
VITE_APP_URL | VITE_ | Server + Client |
VITE_STRIPE_PUBLIC_KEY | VITE_ | Server + Client |
Context
- Never import
.server.tsfiles in client code - Use
VITE_prefix for client-accessible variables - Validate at startup to fail fast on misconfiguration
- Use Zod or similar for runtime validation
- Keep secrets out of error messages and logs
- Consider using
.env.localfor local overrides (gitignored)
err-server-errors: Handle Server Function Errors
Priority: MEDIUM
Explanation
Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production.
Bad Example
// Throwing raw errors - exposes internals
export const createUser = createServerFn({ method: 'POST' })
.validator(createUserSchema)
.handler(async ({ data }) => {
const user = await db.users.create({ data }) // May throw DB error
return user
// Prisma error with stack trace sent to client
})
// Generic error handling - no useful info for client
export const getPost = createServerFn()
.handler(async ({ data }) => {
try {
return await fetchPost(data.id)
} catch (e) {
throw new Error('Something went wrong') // Too vague
}
})Good Example: Structured Error Handling
// lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public status: number = 400
) {
super(message)
this.name = 'AppError'
}
}
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)
}
}
export class ValidationError extends AppError {
constructor(message: string, public fields?: Record<string, string>) {
super(message, 'VALIDATION_ERROR', 400)
}
}Good Example: Server Function with Error Handling
import { createServerFn, notFound } from '@tanstack/react-start'
import { setResponseStatus } from '@tanstack/react-start/server'
export const getPost = createServerFn()
.validator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const post = await db.posts.findUnique({
where: { id: data.id },
})
if (!post) {
// Use built-in notFound for 404s
throw notFound()
}
return post
})
export const createPost = createServerFn({ method: 'POST' })
.validator(createPostSchema)
.handler(async ({ data }) => {
try {
const post = await db.posts.create({ data })
return post
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
// Unique constraint violation
setResponseStatus(409)
throw new AppError('A post with this title already exists', 'DUPLICATE', 409)
}
}
// Log full error server-side
console.error('Failed to create post:', error)
// Return sanitized error to client
setResponseStatus(500)
throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500)
}
})Good Example: 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 if (error instanceof ValidationError) {
// Handle field-specific errors
Object.entries(error.fields ?? {}).forEach(([field, message]) => {
form.setError(field, { 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>}
{/* form fields */}
</form>
)
}Good Example: Using Redirects for Auth Errors
export const updateProfile = createServerFn({ method: 'POST' })
.validator(updateProfileSchema)
.handler(async ({ data }) => {
const session = await getSessionData()
if (!session) {
// Redirect to login for auth errors
throw redirect({
to: '/login',
search: { redirect: '/settings' },
})
}
return await db.users.update({
where: { id: session.userId },
data,
})
})Error Response Best Practices
| Scenario | HTTP Status | Response |
|---|---|---|
| Validation failed | 400 | Field-specific errors |
| Not authenticated | 401 | Redirect to login |
| Not authorized | 403 | Generic forbidden message |
| Resource not found | 404 | Use notFound() |
| Conflict (duplicate) | 409 | Specific conflict message |
| Server error | 500 | Generic message, log details |
Context
- Use
notFound()for 404 errors - integrates with router - Use
redirect()for auth-related errors - Set status codes with
setResponseStatus() - Log full errors server-side, sanitize for client
- Create custom error classes for consistent handling
- Validation errors from
.validator()are automatic
file-separation: Separate Server and Client Code
Priority: LOW
Explanation
Organize code by execution context to prevent server code from accidentally bundling into client builds. Use .server.ts for server-only code, .functions.ts for server function definitions, and standard .ts for shared code.
Bad Example
// lib/posts.ts - Mixed server and client code
import { db } from './db' // Database - server only
import { formatDate } from './utils' // Utility - shared
export async function getPosts() {
// This uses db, so it's server-only
// But file might be imported on client
return db.posts.findMany()
}
export function formatPostDate(date: Date) {
// This could run anywhere
return formatDate(date)
}
// routes/posts.tsx
import { getPosts, formatPostDate } from '@/lib/posts'
// Importing getPosts pulls db into client bundle (error or bloat)Good Example: Clear Separation
lib/
├── posts.ts # Shared types and utilities
├── posts.server.ts # Server-only database logic
├── posts.functions.ts # Server function definitions
└── schemas/
└── post.ts # Shared validation schemas// lib/posts.ts - Shared (safe to import anywhere)
export interface Post {
id: string
title: string
content: string
createdAt: Date
}
export function formatPostDate(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
}).format(date)
}
// lib/posts.server.ts - Server only (never import on client)
import { db } from './db'
import type { Post } from './posts'
export async function getPostsFromDb(): Promise<Post[]> {
return db.posts.findMany({
orderBy: { createdAt: 'desc' },
})
}
export async function createPostInDb(data: CreatePostInput): Promise<Post> {
return db.posts.create({ data })
}
// lib/posts.functions.ts - Server functions (safe to import anywhere)
import { createServerFn } from '@tanstack/react-start'
import { getPostsFromDb, createPostInDb } from './posts.server'
import { createPostSchema } from './schemas/post'
export const getPosts = createServerFn()
.handler(async () => {
return await getPostsFromDb()
})
export const createPost = createServerFn({ method: 'POST' })
.validator(createPostSchema)
.handler(async ({ data }) => {
return await createPostInDb(data)
})Good Example: Using in Components
// components/PostList.tsx
import { getPosts } from '@/lib/posts.functions' // Safe - RPC stub on client
import { formatPostDate } from '@/lib/posts' // Safe - shared utility
import type { Post } from '@/lib/posts' // Safe - type only
function PostList() {
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: () => getPosts(), // Calls server function
})
return (
<ul>
{postsQuery.data?.map((post) => (
<li key={post.id}>
{post.title}
<span>{formatPostDate(post.createdAt)}</span>
</li>
))}
</ul>
)
}File Convention Summary
| Suffix | Purpose | Safe to Import on Client |
|---|---|---|
.ts | Shared utilities, types | Yes |
.server.ts | Server-only logic (db, secrets) | No |
.functions.ts | Server function wrappers | Yes |
.client.ts | Client-only code | Yes (client only) |
Good Example: Environment Variables
// lib/config.server.ts - Server secrets
export const config = {
databaseUrl: process.env.DATABASE_URL!,
sessionSecret: process.env.SESSION_SECRET!,
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
}
// lib/config.ts - Public config (safe for client)
export const publicConfig = {
appName: 'My App',
apiUrl: process.env.NEXT_PUBLIC_API_URL,
stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY,
}
// Never import config.server.ts on clientContext
.server.tsfiles should never be directly imported in client code- Server functions in
.functions.tsare safe - build replaces with RPC - Types from
.server.tsare safe if usingimport type - TanStack Start's build process validates proper separation
- This pattern enables tree-shaking and smaller client bundles
- Use consistent naming convention across your team
mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns
Priority: HIGH
Explanation
Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally.
Bad Example
// Duplicating auth logic in every server function
export const getProfile = createServerFn()
.handler(async () => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// ... rest of handler
})
export const updateProfile = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// ... rest of handler
})
export const deleteAccount = createServerFn({ method: 'POST' })
.handler(async () => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// ... rest of handler
})Good Example: Authentication Middleware
// lib/middleware/auth.ts
import { createMiddleware } from '@tanstack/react-start'
import { getSession } from './session.server'
export const authMiddleware = createMiddleware()
.server(async ({ next }) => {
const session = await getSession()
// Pass session to downstream handlers via context
return next({
context: {
session,
user: session?.user ?? null,
},
})
})
// lib/middleware/requireAuth.ts
export const requireAuthMiddleware = createMiddleware()
.middleware([authMiddleware]) // Depends on auth middleware
.server(async ({ next, context }) => {
if (!context.user) {
throw redirect({ to: '/login' })
}
return next({
context: {
user: context.user, // Now guaranteed to exist
},
})
})Good Example: Logging Middleware
// lib/middleware/logging.ts
export const loggingMiddleware = createMiddleware()
.server(async ({ next, request }) => {
const start = Date.now()
const requestId = crypto.randomUUID()
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
}
})Good Example: Global Middleware Configuration
// app/start.ts
import { createStart } from '@tanstack/react-start/server'
import { loggingMiddleware } from './middleware/logging'
import { authMiddleware } from './middleware/auth'
export default createStart({
// Request middleware runs for all requests
requestMiddleware: [
loggingMiddleware,
authMiddleware,
],
})Good Example: Rate Limiting Middleware
// lib/middleware/rateLimit.ts
import { createMiddleware } from '@tanstack/react-start'
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
export const rateLimitMiddleware = createMiddleware()
.server(async ({ next, request }) => {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const windowMs = 60 * 1000 // 1 minute
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
Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response
// Example with timing:
loggingMiddleware.server(async ({ next }) => {
console.log('Before handler')
const result = await next() // Calls next middleware/handler
console.log('After handler')
return result
})Context
- Request middleware applies to all server requests
- Middleware can add to context using
next({ context: {...} }) - Order matters - first middleware wraps the entire chain
- Global middleware defined in
app/start.ts - Route-specific middleware uses
beforeLoad - Server function middleware uses separate pattern (see
mw-function-middleware)
sf-create-server-fn: Use createServerFn for Server-Side Logic
Priority: CRITICAL
Explanation
createServerFn() creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls.
Bad Example
// Using fetch directly - no type safety, manual serialization
async function createPost(data: CreatePostInput) {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to create post')
return response.json()
}
// Or using API routes - more boilerplate
// api/posts.ts
export async function POST(request: Request) {
const data = await request.json()
// No type safety from client
const post = await db.posts.create({ data })
return new Response(JSON.stringify(post))
}Good Example
// lib/posts.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from './db.server'
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 }) => {
// This code only runs on the server
const post = await db.posts.create({
data: {
title: data.title,
content: data.content,
published: data.published,
},
})
return post
})
// Usage in component
function CreatePostForm() {
const createPostMutation = useServerFn(createPost)
const handleSubmit = async (formData: FormData) => {
try {
const post = await createPostMutation({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
published: false,
},
})
// post is fully typed
console.log('Created post:', post.id)
} catch (error) {
console.error('Failed to create post:', error)
}
}
}Good Example: GET Function for Data Fetching
// lib/posts.functions.ts
export const getPosts = createServerFn() // GET is default
.handler(async () => {
const posts = await db.posts.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
})
return posts
})
export const getPost = createServerFn()
.validator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
const post = await db.posts.findUnique({
where: { id: data.id },
})
if (!post) {
throw notFound()
}
return post
})
// Usage in route loader
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return await getPost({ data: { id: params.postId } })
},
})Good Example: With Context and Dependencies
// Compose 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 }
})Key Benefits
- Type safety: Input/output types flow through client and server
- Automatic serialization: No manual JSON parsing
- Code splitting: Server code never reaches client bundle
- Composable: Call from loaders, components, or other server functions
- Validation: Built-in input validation with schema libraries
Context
- Default method is GET (idempotent, cacheable)
- Use POST for mutations that change data
- Server functions are RPC calls under the hood
- Validation errors are properly typed and serialized
- Import is safe on client - build process replaces with RPC stub
sf-input-validation: Always Validate Server Function Inputs
Priority: CRITICAL
Explanation
Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.
Bad Example
// No validation - trusting client input directly
export const updateUser = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
// data is unknown/any - no type safety
// SQL injection, invalid data, type errors all possible
await db.users.update({
where: { id: data.id },
data: {
name: data.name,
email: data.email,
role: data.role, // Could be set to 'admin' by malicious client!
},
})
})
// Weak validation - type assertion without runtime check
export const deletePost = createServerFn({ method: 'POST' })
.handler(async ({ data }: { data: { id: string } }) => {
// Type assertion doesn't validate at runtime
await db.posts.delete({ where: { id: data.id } })
})Good Example: With Zod Validation
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
const updateUserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
// Don't allow role updates from client input!
})
export const updateUser = createServerFn({ method: 'POST' })
.validator(updateUserSchema)
.handler(async ({ data }) => {
// data is fully typed: { id: string; name: string; email: string }
const user = await db.users.update({
where: { id: data.id },
data: {
name: data.name,
email: data.email,
},
})
return user
})
// Validation errors are automatically returned to client
// with proper status codes and messagesGood Example: Complex Validation
const createOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
})).min(1).max(50),
shippingAddress: z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
}),
couponCode: z.string().optional(),
})
export const createOrder = createServerFn({ method: 'POST' })
.validator(createOrderSchema)
.handler(async ({ data }) => {
// All data is validated and typed
// Process order safely
})Good Example: Transform and Refine
const registrationSchema = z.object({
email: z.string().email().toLowerCase(), // Transform to lowercase
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[0-9]/, 'Password must contain number'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords must match', path: ['confirmPassword'] }
)
export const register = createServerFn({ method: 'POST' })
.validator(registrationSchema)
.handler(async ({ data }) => {
// Passwords match, email is lowercase
// Only password needed (confirmPassword was for validation)
const hashedPassword = await hashPassword(data.password)
return await createUser({
email: data.email,
password: hashedPassword,
})
})Sharing Schemas Between Client and Server
// lib/schemas/post.ts - Shared validation schema
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10).optional(),
})
export type CreatePostInput = z.infer<typeof createPostSchema>
// lib/posts.functions.ts - Server function
import { createPostSchema } from './schemas/post'
export const createPost = createServerFn({ method: 'POST' })
.validator(createPostSchema)
.handler(async ({ data }) => { /* ... */ })
// components/CreatePostForm.tsx - Client form validation
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'
function CreatePostForm() {
const form = useForm<CreatePostInput>({
resolver: zodResolver(createPostSchema),
})
// Same validation client and server side
}Context
- Network boundary = trust boundary - always validate
- Use
.validator()before.handler()in the chain - Validation errors return proper HTTP status codes
- Share schemas between client forms and server functions
- Strip or ignore fields clients shouldn't control (like
role,isAdmin) - Consider rate limiting for mutation endpoints
ssr-hydration-safety: Prevent Hydration Mismatches
Priority: MEDIUM
Explanation
Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client.
Bad Example
// Using Date.now() - different on server and client
function Timestamp() {
return <span>Generated at: {Date.now()}</span>
}
// Using Math.random() - always different
function RandomGreeting() {
const greetings = ['Hello', 'Hi', 'Hey']
return <h1>{greetings[Math.floor(Math.random() * 3)]}</h1>
}
// Checking window - doesn't exist on server
function DeviceInfo() {
return <span>Width: {window.innerWidth}px</span> // Error on server
}
// Conditional render based on time
function TimeBasedContent() {
const hour = new Date().getHours()
return hour < 12 ? <Morning /> : <Evening />
// Server might render Morning, client renders Evening
}Good Example: Consistent Server/Client Rendering
// Pass data from server to avoid mismatch
export const Route = createFileRoute('/dashboard')({
loader: async () => {
return {
generatedAt: Date.now(),
}
},
component: Dashboard,
})
function Dashboard() {
const { generatedAt } = Route.useLoaderData()
// Both server and client use same value
return <span>Generated at: {generatedAt}</span>
}Good Example: Client-Only Components
// Use lazy loading for client-only features
import { lazy, Suspense } from 'react'
const ClientOnlyMap = lazy(() => import('./Map'))
function LocationPage() {
return (
<div>
<h1>Our Location</h1>
<Suspense fallback={<MapPlaceholder />}>
<ClientOnlyMap />
</Suspense>
</div>
)
}
// Or use useEffect for client-only state
function WindowSize() {
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
useEffect(() => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
})
}, [])
if (!size) {
return <span>Loading dimensions...</span>
}
return <span>{size.width} x {size.height}</span>
}Good Example: Stable Random Values
// Generate random value on server, pass to client
export const Route = createFileRoute('/onboarding')({
loader: () => ({
welcomeVariant: Math.floor(Math.random() * 3),
}),
component: Onboarding,
})
function Onboarding() {
const { welcomeVariant } = Route.useLoaderData()
const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!']
return <h1>{messages[welcomeVariant]}</h1> // Same on server and client
}Good Example: Handling Time Zones
// Pass formatted date from server
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId)
return {
...post,
// Format on server to avoid timezone mismatch
formattedDate: new Intl.DateTimeFormat('en-US', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'UTC', // Consistent timezone
}).format(post.createdAt),
}
},
component: PostPage,
})
// Or use client-only formatting
function RelativeTime({ date }: { date: Date }) {
const [formatted, setFormatted] = useState<string>('')
useEffect(() => {
// Format in user's timezone after hydration
setFormatted(formatDistanceToNow(date, { addSuffix: true }))
}, [date])
// Show absolute date initially (same server/client)
return <time dateTime={date.toISOString()}>
{formatted || date.toISOString().split('T')[0]}
</time>
}Common Hydration Mismatch Causes
| Issue | 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 differences | Use UTC or client-only formatting |
| Browser-specific APIs | Check typeof window !== 'undefined' |
| Extension-injected content | Use suppressHydrationWarning |
Debugging Hydration Errors
// React 18+ provides detailed hydration error messages
// Check the console for:
// - "Text content does not match"
// - "Hydration failed because"
// - The specific DOM element causing the issue
// For difficult cases, use suppressHydrationWarning sparingly
function UserContent({ html }: { html: string }) {
return (
<div
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}Context
- Hydration compares server HTML with client render
- Mismatches force full client re-render (slow, flash)
- Use loaders to pass dynamic data consistently
- Defer client-only content with useEffect or Suspense
- Test SSR by disabling JavaScript and checking render
- Development mode shows hydration warnings in console
ssr-prerender: Configure Static Prerendering and ISR
Priority: MEDIUM
Explanation
Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs.
Bad Example
// SSR for completely static content - wasteful
export const Route = createFileRoute('/about')({
loader: async () => {
// Fetching static content on every request
const content = await fetchAboutPageContent()
return { content }
},
})
// Or no caching headers for semi-static content
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug)
return { post }
// Every request hits the database
},
})Good Example: Static Prerendering
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
prerender: {
// Routes to prerender at build time
routes: [
'/',
'/about',
'/contact',
'/pricing',
],
// Or crawl from root
crawlLinks: true,
},
},
})
// routes/about.tsx - Will be prerendered
export const Route = createFileRoute('/about')({
loader: async () => {
// Runs at BUILD time, not request time
const content = await fetchAboutPageContent()
return { content }
},
component: AboutPage,
})Good Example: Dynamic Prerendering
// app.config.ts
export default defineConfig({
server: {
prerender: {
// Generate routes dynamically
routes: async () => {
const posts = await db.posts.findMany({
where: { published: true },
select: { slug: true },
})
return [
'/',
'/blog',
...posts.map(p => `/blog/${p.slug}`),
]
},
},
},
})Good Example: ISR with Revalidation
// routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { setHeaders } from '@tanstack/react-start/server'
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug)
// ISR: Cache for 60 seconds, then revalidate
setHeaders({
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
})
return { post }
},
component: BlogPost,
})
// First request: SSR and cache
// Next 60 seconds: Serve cached version
// After 60 seconds: Serve stale, revalidate in background
// After 300 seconds: Full SSR againGood Example: Hybrid Static/Dynamic
// routes/products.tsx - Prerendered
export const Route = createFileRoute('/products')({
loader: async () => {
// Featured products - prerendered at build
const featured = await fetchFeaturedProducts()
return { featured }
},
})
// routes/products/$productId.tsx - ISR
export const Route = createFileRoute('/products/$productId')({
loader: async ({ params }) => {
const product = await fetchProduct(params.productId)
if (!product) throw notFound()
// Cache product pages for 5 minutes
setHeaders({
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
})
return { product }
},
})
// routes/cart.tsx - Always SSR (user-specific)
export const Route = createFileRoute('/cart')({
loader: async ({ context }) => {
// No caching - user-specific data
setHeaders({
'Cache-Control': 'private, no-store',
})
const cart = await fetchUserCart(context.user.id)
return { cart }
},
})Good Example: On-Demand Revalidation
// API route to trigger revalidation
// app/routes/api/revalidate.ts
export const APIRoute = createAPIFileRoute('/api/revalidate')({
POST: async ({ request }) => {
const { secret, path } = await request.json()
// Verify secret
if (secret !== process.env.REVALIDATE_SECRET) {
return json({ error: 'Invalid secret' }, { status: 401 })
}
// Trigger revalidation (implementation depends on hosting)
await revalidatePath(path)
return json({ revalidated: true, path })
},
})
// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" }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 |
Context
- Prerendering happens at build time - no request context
- ISR requires CDN/edge support (Vercel, Cloudflare, etc.)
- Use prerendering for truly static pages (about, pricing)
- Use ISR for content that changes but not per-request
- Always SSR for user-specific or real-time data
- Test with production builds - dev server is always SSR
ssr-streaming: Implement Streaming SSR for Faster TTFB
Priority: MEDIUM
Explanation
Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively.
Bad Example
// Blocking SSR - waits for everything
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
// All of these must complete before ANY HTML is sent
await Promise.all([
queryClient.ensureQueryData(userQueries.profile()), // 200ms
queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms
queryClient.ensureQueryData(activityQueries.recent()), // 300ms
queryClient.ensureQueryData(notificationQueries.all()), // 400ms
])
// TTFB: 500ms (slowest query)
},
})Good Example: Stream Non-Critical Content
// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
// Only await critical above-the-fold data
await queryClient.ensureQueryData(userQueries.profile())
// Start fetching but don't await
queryClient.prefetchQuery(dashboardQueries.stats())
queryClient.prefetchQuery(activityQueries.recent())
queryClient.prefetchQuery(notificationQueries.all())
// HTML starts streaming immediately after profile loads
// TTFB: 200ms
},
component: DashboardPage,
})
function DashboardPage() {
// Critical data - ready immediately (from loader)
const { data: user } = useSuspenseQuery(userQueries.profile())
return (
<div>
<Header user={user} />
{/* Non-critical - streams in with Suspense */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<NotificationsList />
</Suspense>
</div>
)
}
// Each section loads independently and streams when ready
function DashboardStats() {
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
return <StatsDisplay stats={stats} />
}Good Example: Nested Suspense Boundaries
function DashboardPage() {
const { data: user } = useSuspenseQuery(userQueries.profile())
return (
<div>
<Header user={user} />
<div className="grid grid-cols-2 gap-4">
{/* Left column streams together */}
<Suspense fallback={<LeftColumnSkeleton />}>
<LeftColumn />
</Suspense>
{/* Right column streams independently */}
<Suspense fallback={<RightColumnSkeleton />}>
<RightColumn />
</Suspense>
</div>
</div>
)
}
function LeftColumn() {
// These load together (same Suspense boundary)
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
const { data: chart } = useSuspenseQuery(dashboardQueries.chartData())
return (
<div>
<StatsCard stats={stats} />
<ChartDisplay data={chart} />
</div>
)
}Good Example: Progressive Enhancement
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Critical: post content (await)
await queryClient.ensureQueryData(postQueries.detail(params.postId))
// Start but don't block: comments, related posts
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>
{/* Streams immediately */}
<PostHeader post={post} />
<PostContent content={post.content} />
{/* Streams when ready */}
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection postId={postId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedPosts postId={postId} />
</Suspense>
</article>
)
}Good Example: Error Boundaries with Streaming
function DashboardPage() {
return (
<div>
<Header />
{/* Each section handles its own errors */}
<ErrorBoundary fallback={<StatsError />}>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<ActivityError />}>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</ErrorBoundary>
</div>
)
}Streaming Timeline
Traditional SSR:
Request → [Wait for all data...] → Send complete HTML → Render
Streaming SSR:
Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done
↓ ↓ ↓ ↓
Browser renders Shows content More content Complete
skeleton progressivelyContext
- Suspense boundaries define streaming chunks
- Place boundaries around slow or non-critical content
- Critical path data should still be awaited in loader
- Each Suspense boundary can error independently
- Works with React 18's streaming SSR
- Monitor TTFB to verify streaming is working
- Consider network conditions - too many chunks can slow total load
Related skills
FAQ
What does tanstack-start-best-practices do?
TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
When should I use tanstack-start-best-practices?
TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
Is tanstack-start-best-practices safe to install?
Review the Security Audits panel on this page before installing in production.