rules/api-routes.md
# 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
```tsx
// 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 semantics
```
## Good Example: Basic Server Route
```tsx
// routes/api/users.ts
import { createFileRoute } from '@tanstack/react-router'
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 Response.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 Response.json({ error: parsed.error.flatten() }, { status: 400 })
}
const user = await db.users.create({ data: parsed.data })
return Response.json(user, { status: 201 })
},
},
},
})
```
## Good Example: Webhook Handler
```tsx
// 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
```tsx
// routes/api/posts/$postId.ts
import { createFileRoute } from '@tanstack/react-router'
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 Response.json({ error: 'Post not found' }, { status: 404 })
}
return Response.json(post)
},
PUT: async ({ request, params }) => {
const body = await request.json()
const parsed = updatePostSchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
}
const post = await db.posts.update({
where: { id: params.postId },
data: parsed.data,
})
return Response.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
```tsx
// routes/api/protected/data.ts
import { createFileRoute } from '@tanstack/react-router'
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 Response.json(data)
},
},
},
})
```
## Good Example: Using createHandlers for Handler-Specific Middleware
```tsx
// routes/api/admin/users.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/admin/users')({
server: {
middleware: [authMiddleware], // All handlers require auth
handlers: ({ createHandlers }) =>
createHandlers({
GET: async ({ context }) => {
const users = await db.users.findMany()
return Response.json(users)
},
// DELETE requires additional admin middleware
DELETE: {
middleware: [adminOnlyMiddleware],
handler: async ({ request, context }) => {
const { userId } = await request.json()
await db.users.delete({ where: { id: userId } })
return Response.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 `createFileRoute` with a `server.handlers` property
- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
- Use `Response.json()` for JSON responses
- Return `Response` objects for custom formats
- Handler receives `{ request, params, context }` object
- Ideal for: webhooks, public APIs, file downloads, third-party integrations
- Consider versioning: `/api/v1/users` for public APIs
rules/auth-cookie-security.md
# auth-cookie-security: Configure Secure Cookie Settings
## Priority: HIGH
## Explanation
Session cookies are the primary attack surface for authentication. Misconfigured cookies can expose sessions to XSS, CSRF, and man-in-the-middle attacks. Always use `httpOnly`, `secure`, and `sameSite` settings.
## Bad Example
```tsx
// Minimal session config - missing security settings
export function useAppSession() {
return useSession({
password: 'short', // Too short, hardcoded
})
}
// Storing token in localStorage
function login(token: string) {
localStorage.setItem('token', token) // XSS can steal this
}
// Non-httpOnly cookie
export function useAppSession() {
return useSession({
password: process.env.SESSION_SECRET!,
cookie: {
httpOnly: false, // JavaScript can read the session cookie
secure: false, // Sent over HTTP in production
},
})
}
```
## Good Example
```tsx
import { useSession } from '@tanstack/react-start/server'
type SessionData = {
userId?: string
email?: string
role?: string
}
export function useAppSession() {
return useSession<SessionData>({
name: 'app-session',
password: process.env.SESSION_SECRET!, // At least 32 characters
cookie: {
httpOnly: true, // Not accessible via document.cookie / JavaScript
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // Blocks cross-site POST but allows navigation
maxAge: 60 * 60 * 24 * 7, // 7 days
},
})
}
```
## Good Example: Session Operations
```tsx
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
export const login = createServerFn({ method: 'POST' })
.inputValidator(loginSchema)
.handler(async ({ data }) => {
const user = await verifyCredentials(data.email, data.password)
if (!user) throw new Error('Invalid credentials')
const session = await useAppSession()
await session.update({
userId: user.id,
email: user.email,
role: user.role,
})
throw redirect({ to: '/dashboard' })
})
export const logout = createServerFn({ method: 'POST' })
.handler(async () => {
const session = await useAppSession()
await session.clear() // Destroys the session
throw redirect({ to: '/' })
})
```
## Cookie Security Settings Reference
| Setting | Recommended | Purpose |
|---------|-------------|---------|
| `httpOnly` | `true` | Prevents XSS from reading cookie via JavaScript |
| `secure` | `true` in prod | Cookie only sent over HTTPS |
| `sameSite` | `'lax'` | Blocks cross-site POST requests (CSRF protection) |
| `maxAge` | App-specific | Session duration in seconds |
| `password` | 32+ random chars | Encryption key for cookie contents |
## Context
- Generate `SESSION_SECRET` with `openssl rand -base64 32`
- `sameSite: 'lax'` allows cookies on top-level navigations (needed for OAuth callbacks)
- `sameSite: 'strict'` provides stronger CSRF protection but may break OAuth flows
- Store minimal data in the session (user ID, role) — fetch full user data on demand
- Implement session rotation on privilege changes (login, role change)
- Consider clearing sessions on password change
- `useSession` is from `@tanstack/react-start/server`
rules/auth-route-protection.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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 only
```
## Good Example: Preserving Redirect URL
```tsx
// 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
```tsx
// 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
- `beforeLoad` runs before route loading begins
- Throwing `redirect()` prevents route from loading
- Context from `beforeLoad` flows to loader and component
- Child routes inherit parent's `beforeLoad` protection
- Use pathless layout routes (`_authenticated.tsx`) for grouped protection
- Store redirect URL in search params for post-login navigation
rules/auth-server-functions.md
# auth-server-functions: Verify Auth in Server Functions
## Priority: HIGH
## Explanation
Every server function that accesses user-specific data or performs mutations must verify authentication. Use middleware to centralize auth checks, or call your session utility directly. Never trust client-side auth state alone — always verify on the server.
## Bad Example
```tsx
// Trusting a userId passed from the client
export const getUserData = createServerFn()
.inputValidator((data: { userId: string }) => data)
.handler(async ({ data }) => {
// Anyone can pass any userId! No server-side auth check
return db.users.findUnique({ where: { id: data.userId } })
})
// Checking auth on the client only
function ProfilePage() {
const { user } = useAuth() // Client-side only
if (!user) return <Navigate to="/login" />
// If someone calls getProfile() directly, there's no server check
return <Profile />
}
```
## Good Example: Auth via Middleware
```tsx
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { useSession } from '@tanstack/react-start/server'
import { redirect } from '@tanstack/react-router'
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const session = await useAppSession()
if (!session.data.userId) {
throw redirect({ to: '/login' })
}
return next({
context: { userId: session.data.userId },
})
})
// User can only access their own data - userId comes from session, not client
export const getMyProfile = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.users.findUnique({
where: { id: context.userId },
select: { id: true, name: true, email: true, avatar: true },
})
})
export const updateMyProfile = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(z.object({
name: z.string().min(1).max(100),
}))
.handler(async ({ data, context }) => {
return db.users.update({
where: { id: context.userId },
data: { name: data.name },
})
})
```
## Good Example: Direct Session Check
```tsx
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
export const getCurrentUser = createServerFn()
.handler(async () => {
const session = await useAppSession()
const userId = session.data.userId
if (!userId) return null
return db.users.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
// Never include passwordHash or secrets
},
})
})
// Use in root route to make user available everywhere
export const Route = createRootRoute({
beforeLoad: async () => {
const user = await getCurrentUser()
return { user }
},
})
```
## Context
- Always derive the current user from the server-side session, never from client input
- Use middleware for consistent auth across multiple server functions
- Use `beforeLoad` on layout routes to protect entire route trees
- Return `null` (not throw) from `getCurrentUser` when auth is optional
- Throw `redirect()` when auth is required and the user is missing
- Never return sensitive fields (password hashes, tokens) from auth server functions
rules/auth-session-management.md
# 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
```tsx
// 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
```tsx
// 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' })
.inputValidator(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
```tsx
// 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' })
.inputValidator(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
```tsx
// 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_SECRET` with `openssl 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
rules/deploy-adapters.md
# deploy-adapters: Choose Appropriate Deployment Adapter
## Priority: LOW
## Explanation
TanStack Start is built on Vite and supports deployment to any hosting provider via platform-specific Vite plugins. Each adapter optimizes the build output for its platform's runtime. Configuration is done in `vite.config.ts` using `tanstackStart()` from `@tanstack/react-start/plugin/vite`.
## Bad Example
```tsx
// Using the old app.config.ts pattern - no longer valid
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'vercel', // Old preset-based config
},
})
```
## Good Example: Cloudflare Workers
```bash
pnpm add -D @cloudflare/vite-plugin wrangler
```
```ts
// 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(),
],
})
```
```jsonc
// wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "tanstack-start-app",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry"
}
```
## Good Example: Netlify
```bash
npm install -D @netlify/vite-plugin-tanstack-start
```
```ts
// 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(),
],
})
```
## Good Example: Nitro (Vercel, Node.js, Docker)
```json
{
"dependencies": {
"nitro": "npm:nitro-nightly@latest"
}
}
```
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { nitro } from 'nitro/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart(),
nitro(),
viteReact(),
],
})
```
```dockerfile
# Dockerfile for Node.js deployment
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"]
```
## Good Example: Bun
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { nitro } from 'nitro/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart(),
nitro({ preset: 'bun' }),
viteReact(),
],
})
```
## Adapter Comparison
| Platform | Plugin | Notes |
|----------|--------|-------|
| Cloudflare Workers | `@cloudflare/vite-plugin` | Official partner, edge runtime |
| Netlify | `@netlify/vite-plugin-tanstack-start` | Official partner |
| Railway | Nitro adapter | Auto-detects, push to deploy |
| Vercel | Nitro adapter | Via `nitro/vite` |
| Node.js / Docker | Nitro adapter | `node .output/server/index.mjs` |
| Bun | Nitro with `preset: 'bun'` | Requires React 19 |
| Appwrite Sites | Standard build | Configure output dir in dashboard |
## Context
- All deployment config is in `vite.config.ts` — there is no `app.config.ts`
- `tanstackStart()` must come BEFORE `viteReact()` in the plugins array
- Cloudflare and Netlify have dedicated first-party plugins
- All other platforms use the generic Nitro adapter (`nitro/vite`)
- Edge runtimes have API limitations (no file system, limited Node.js APIs)
- Test locally with `vite build && vite preview`
- ISR uses standard HTTP `Cache-Control` headers, not a framework-specific API
rules/deploy-env-config.md
# deploy-env-config: Configure Environment Variables Correctly
## Priority: LOW
## Explanation
TanStack Start follows Vite's environment variable conventions. Only `VITE_`-prefixed variables are available on the client via `import.meta.env`. All variables are available in server functions via `process.env`. Use type declarations and runtime validation to catch misconfiguration early.
## Bad Example
```tsx
// Exposing secrets via VITE_ prefix — included in client bundle!
// .env
VITE_DATABASE_URL=postgresql://user:pass@host/db
VITE_API_SECRET=sk-secret-key
// Accessing non-VITE_ vars on the client — undefined at runtime
function AppHeader() {
return <h1>{process.env.APP_NAME}</h1> // undefined on client
}
// No validation — silent failures in production
const db = connect(process.env.DATABASE_URL) // Could be undefined
```
## Good Example: Environment Variable Separation
```bash
# .env — committed to git (no secrets)
VITE_APP_NAME=MyApp
VITE_API_URL=http://localhost:3000/api
# .env.local — gitignored (secrets and local overrides)
DATABASE_URL=postgresql://user:pass@localhost/mydb
SESSION_SECRET=your-32-char-secret-here
API_SECRET=sk-secret-key
```
```tsx
// Server function — access any env var
import { createServerFn } from '@tanstack/react-start'
const getConfig = createServerFn().handler(async () => {
// Safe: server-only, never in client bundle
const dbUrl = process.env.DATABASE_URL
return { connected: !!dbUrl }
})
// Component — only VITE_ prefixed vars
function AppHeader() {
return <h1>{import.meta.env.VITE_APP_NAME}</h1>
}
```
## Good Example: Type Declarations
```tsx
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string
readonly SESSION_SECRET: string
readonly API_SECRET: string
readonly NODE_ENV: 'development' | 'production' | 'test'
}
}
}
export {}
```
## Good Example: Runtime Validation with Zod
```tsx
// utils/env.server.ts — validate on startup
import { z } from 'zod'
const serverEnvSchema = z.object({
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
API_SECRET: z.string().min(1),
NODE_ENV: z.enum(['development', 'production', 'test']),
})
export const env = serverEnvSchema.parse(process.env)
// utils/env.client.ts — validate client env
const clientEnvSchema = z.object({
VITE_APP_NAME: z.string().min(1),
VITE_API_URL: z.string().url(),
})
export const clientEnv = clientEnvSchema.parse(import.meta.env)
```
## Good Example: Runtime Client Variables via Server Functions
```tsx
// For env vars that shouldn't be in the client bundle but are needed at runtime
const getRuntimeConfig = createServerFn().handler(async () => {
return {
featureFlags: JSON.parse(process.env.FEATURE_FLAGS ?? '{}'),
apiVersion: process.env.API_VERSION ?? 'v1',
}
})
export const Route = createFileRoute('/')({
loader: () => getRuntimeConfig(),
component: App,
})
```
## .env File Loading Order
```
.env # Default values (commit to git)
.env.development # Dev-specific overrides
.env.production # Production-specific overrides
.env.local # Local overrides (always gitignored)
```
## Context
- `VITE_` prefixed variables: client-safe, available via `import.meta.env`
- Non-prefixed variables: server-only, available via `process.env` in server functions
- Never prefix secrets with `VITE_` — they will be in the client bundle
- Use `.env.local` for secrets during development (gitignored by default)
- Type declarations in `src/env.d.ts` give autocompletion and type checking
- Runtime validation catches missing/malformed env vars at startup
- Use server functions to safely pass non-`VITE_` values to the client at runtime
rules/env-functions.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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-only
```
## Good Example: Environment-Specific Behavior
```tsx
// 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
```tsx
// 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
```tsx
// 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.ts` files 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.local` for local overrides (gitignored)
rules/err-not-found.md
# err-not-found: Handle Not-Found Scenarios
## Priority: MEDIUM
## Explanation
Use `throw notFound()` when a requested resource doesn't exist. This triggers the nearest `notFoundComponent` in the route tree, providing a consistent not-found experience. The router also automatically throws not-found for unmatched URL paths.
## Bad Example
```tsx
// Returning null/undefined instead of throwing notFound
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId)
return post // Returns undefined if not found - component crashes
},
component: PostComponent,
})
function PostComponent() {
const post = Route.useLoaderData()
// post might be undefined - renders broken UI instead of 404
return <h1>{post.title}</h1>
}
// Using error boundaries for not-found - wrong semantics
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId)
if (!post) throw new Error('Post not found') // Shows error UI, not 404
return post
},
})
```
## Good Example
```tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
// Throw notFound in server functions
const getPost = createServerFn()
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } })
if (!post) {
throw notFound()
}
return post
})
// Route with notFoundComponent
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return getPost({ data: { id: params.postId } })
},
notFoundComponent: () => {
return (
<div>
<h2>Post not found</h2>
<p>The post you're looking for doesn't exist or has been removed.</p>
<Link to="/posts">Browse all posts</Link>
</div>
)
},
component: PostComponent,
})
function PostComponent() {
const post = Route.useLoaderData()
// post is guaranteed to exist — notFound was thrown otherwise
return <h1>{post.title}</h1>
}
```
## Good Example: Not-Found in beforeLoad
```tsx
// Throwing notFound in beforeLoad targets the root notFoundComponent
export const Route = createFileRoute('/users/$username')({
beforeLoad: async ({ params }) => {
const user = await getUserByUsername(params.username)
if (!user) throw notFound()
return { user }
},
component: UserProfile,
})
// Root not-found handler
export const Route = createRootRoute({
notFoundComponent: () => (
<div>
<h1>404 — Page not found</h1>
<Link to="/">Go home</Link>
</div>
),
})
```
## Good Example: Targeting a Specific Route
```tsx
import { notFound, rootRouteId } from '@tanstack/react-router'
export const Route = createFileRoute('/org/$orgId/project/$projectId')({
loader: async ({ params }) => {
const project = await getProject(params.projectId)
if (!project) {
// Target the org layout's notFoundComponent instead of this route's
throw notFound({ routeId: '/org/$orgId' })
}
return project
},
})
```
## Context
- `throw notFound()` triggers the nearest `notFoundComponent` up the route tree
- Throwing in `beforeLoad` always targets the root `notFoundComponent`
- Throwing in `loader` targets the current route's `notFoundComponent`
- Use `notFound({ routeId })` to target a specific route's not-found handler
- Router `notFoundMode: 'fuzzy'` (default) finds the closest parent with `notFoundComponent`
- Router `notFoundMode: 'root'` sends all not-founds to the root route
- `NotFoundRoute` is deprecated — use `notFoundComponent` on routes instead
- `notFound` is from `@tanstack/react-router`
rules/err-redirects.md
# err-redirects: Use Redirects Appropriately
## Priority: MEDIUM
## Explanation
TanStack Start uses `throw redirect()` for server-side navigation control. Redirects work in server functions, `beforeLoad`, and loaders. They trigger client-side navigation and are distinct from errors — use `isRedirect()` to distinguish them in try/catch blocks.
## Bad Example
```tsx
// Returning a redirect URL instead of throwing - client must handle manually
export const checkAuth = createServerFn()
.handler(async () => {
const user = await getCurrentUser()
if (!user) {
return { redirect: '/login' } // Client must check and navigate
}
return { user }
})
// Catching redirects as errors
export const Route = createFileRoute('/_authed')({
beforeLoad: async () => {
try {
return await checkAuth()
} catch (error) {
// This catches the redirect too! It never navigates
console.error('Auth failed:', error)
return { user: null }
}
},
})
```
## Good Example
```tsx
import { redirect, isRedirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
// Throw redirect for navigation control
export const requireAuth = createServerFn()
.handler(async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
return user
})
// Redirect with search params to preserve return URL
export const requireAuthWithReturn = createServerFn()
.handler(async () => {
const request = getRequest()
const user = await getCurrentUser()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: new URL(request.url).pathname },
})
}
return user
})
// Redirect after mutation
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
const post = await db.posts.create({ data })
throw redirect({ to: '/posts/$postId', params: { postId: post.id } })
})
```
## Good Example: Handling Redirects in try/catch
```tsx
import { redirect, isRedirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
try {
const user = await verifySession()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { user }
} catch (error) {
// Always re-throw redirects — they are control flow, not errors
if (isRedirect(error)) throw error
// Handle actual errors
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
```
## Good Example: External Redirects
```tsx
// Use href for external URLs (not to)
export const startOAuth = createServerFn({ method: 'POST' })
.handler(async () => {
const authUrl = buildOAuthUrl()
throw redirect({ href: authUrl }) // href for full URLs
})
```
## Context
- `throw redirect({ to: '/path' })` for internal navigation (type-safe route paths)
- `throw redirect({ href: 'https://...' })` for external URLs
- `throw redirect({ to: '/path', search: { key: 'value' } })` to include search params
- Redirects work in: server functions, `beforeLoad`, loaders
- Always use `isRedirect(error)` in try/catch to re-throw redirects
- Redirect is from `@tanstack/react-router`, not `@tanstack/react-start`
- After a `throw redirect()`, no further code in the handler executes
rules/err-server-errors.md
# 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
```tsx
// Throwing raw errors - exposes internals
export const createUser = createServerFn({ method: 'POST' })
.inputValidator(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
```tsx
// 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
```tsx
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) {
// Use built-in notFound for 404s
throw notFound()
}
return post
})
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(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
```tsx
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
```tsx
export const updateProfile = createServerFn({ method: 'POST' })
.inputValidator(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 `.inputValidator()` are automatic
rules/file-functions-file.md
# file-functions-file: Use .functions.ts Pattern for Server Functions
## Priority: LOW
## Explanation
Organize server functions in `.functions.ts` files to clearly separate RPC wrappers from server-only implementation details. Static imports of server function files are safe anywhere — the bundler replaces handler implementations with RPC stubs in client bundles.
## Bad Example
```tsx
// Mixing server functions with component code
// routes/posts.tsx
import { createServerFn } from '@tanstack/react-start'
import { db } from '../lib/db.server' // Server import in route file
const getPosts = createServerFn().handler(async () => {
return db.posts.findMany()
})
const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
return db.posts.create({ data })
})
// 200 lines of server functions mixed with component code...
export const Route = createFileRoute('/posts')({
loader: () => getPosts(),
component: PostsPage,
})
```
## Good Example
```
src/
├── utils/
│ ├── posts.functions.ts # Server function wrappers (createServerFn)
│ ├── posts.server.ts # Server-only helpers (direct DB, internal logic)
│ └── posts.schema.ts # Shared validation schemas (client-safe)
├── routes/
│ └── posts.tsx # Route definition — clean, focused
```
```tsx
// utils/posts.schema.ts — shared between client and server
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
published: z.boolean().default(false),
})
// utils/posts.server.ts — server-only implementation
import { db } from '../lib/db'
export async function findPosts(offset: number, limit: number) {
return db.posts.findMany({
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
})
}
export async function insertPost(data: { title: string; content: string }) {
return db.posts.create({ data })
}
// utils/posts.functions.ts — server function wrappers
import { createServerFn } from '@tanstack/react-start'
import { createPostSchema } from './posts.schema'
export const getPosts = createServerFn()
.inputValidator((data: { offset: number; limit: number }) => data)
.handler(async ({ data }) => {
const { findPosts } = await import('./posts.server')
return findPosts(data.offset, data.limit)
})
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
const { insertPost } = await import('./posts.server')
return insertPost(data)
})
// routes/posts.tsx — clean route file
import { createFileRoute } from '@tanstack/react-router'
import { getPosts } from '../utils/posts.functions'
export const Route = createFileRoute('/posts')({
loader: () => getPosts({ data: { offset: 0, limit: 20 } }),
component: PostsPage,
})
```
## Context
- `.functions.ts` — `createServerFn` wrappers, safe to import anywhere
- `.server.ts` — server-only helpers (database queries, internal logic)
- `.schema.ts` or `schemas.ts` — validation schemas shared between client and server
- Static imports of `.functions.ts` files are safe — the bundler replaces handlers with RPC stubs
- Do NOT use dynamic imports for server functions themselves — only for server-only helpers inside handlers
- This pattern keeps route files focused on routing concerns
- Group by domain (posts, users, auth) rather than by layer
rules/file-separation.md
# 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
```tsx
// 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
```
```tsx
// 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' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
return await createPostInDb(data)
})
```
## Good Example: Using in Components
```tsx
// 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
```tsx
// 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 client
```
## Context
- `.server.ts` files should never be directly imported in client code
- Server functions in `.functions.ts` are safe - build replaces with RPC
- Types from `.server.ts` are safe if using `import 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
rules/file-shared-validation.md
# file-shared-validation: Share Validation Schemas Between Client and Server
## Priority: LOW
## Explanation
Validation schemas (Zod, Valibot, etc.) are safe to share between client and server. Define them in dedicated files and reuse them in server function `.inputValidator()`, form validation, and search param validation. This ensures consistent validation rules and types on both sides.
## Bad Example
```tsx
// Duplicating validation logic
// server
export const createUser = createServerFn({ method: 'POST' })
.inputValidator((data: { name: string; email: string }) => {
if (!data.name || data.name.length > 100) throw new Error('Invalid name')
if (!data.email?.includes('@')) throw new Error('Invalid email')
return data
})
.handler(async ({ data }) => { /* ... */ })
// client — different rules, easy to drift
function CreateUserForm() {
const validate = (values) => {
const errors = {}
if (!values.name) errors.name = 'Required'
if (values.name?.length > 50) errors.name = 'Too long' // Different max!
if (!values.email) errors.email = 'Required'
return errors
}
}
```
## Good Example
```tsx
// utils/schemas/user.schema.ts — single source of truth
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long').trim(),
email: z.string().email('Invalid email address').max(255),
role: z.enum(['user', 'editor']).default('user'),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export const updateUserSchema = createUserSchema.partial().required({ name: true })
export type UpdateUserInput = z.infer<typeof updateUserSchema>
```
```tsx
// utils/users.functions.ts — server uses the same schema
import { createServerFn } from '@tanstack/react-start'
import { createUserSchema } from './schemas/user.schema'
export const createUser = createServerFn({ method: 'POST' })
.inputValidator(createUserSchema)
.handler(async ({ data }) => {
// data is validated and typed as CreateUserInput
return db.users.create({ data })
})
```
```tsx
// components/CreateUserForm.tsx — client uses the same schema
import { createUserSchema, type CreateUserInput } from '../utils/schemas/user.schema'
function CreateUserForm() {
const [errors, setErrors] = useState<Record<string, string>>({})
const handleSubmit = async (formData: FormData) => {
const raw = Object.fromEntries(formData)
const result = createUserSchema.safeParse(raw)
if (!result.success) {
// Show validation errors before hitting the server
setErrors(result.error.flatten().fieldErrors)
return
}
await createUser({ data: result.data })
}
}
```
## Good Example: Search Param Validation
```tsx
// utils/schemas/search.schema.ts
import { z } from 'zod'
export const postsSearchSchema = z.object({
q: z.string().optional(),
page: z.number().default(1),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
})
// routes/posts.tsx — reuse in route search validation
export const Route = createFileRoute('/posts')({
validateSearch: postsSearchSchema,
loaderDeps: ({ search }) => ({ search }),
loader: ({ deps }) => getPosts({ data: deps.search }),
})
```
## Context
- Validation schemas contain no server-side code — safe to import on the client
- Zod schemas can be passed directly to `.inputValidator()` on server functions
- Schemas provide both runtime validation and TypeScript types via `z.infer<typeof schema>`
- Client-side validation improves UX with instant feedback before server round-trip
- Server-side validation is still required — client validation can be bypassed
- Put schemas in a `schemas/` directory or use `.schema.ts` suffix
- Keep schemas close to their domain (user.schema.ts, post.schema.ts)
rules/mw-composability.md
# mw-composability: Compose Middleware Effectively
## Priority: HIGH
## Explanation
Middleware can depend on other middleware via `.middleware([...])`, forming a composition chain. This lets you build layered concerns (logging → auth → permissions) without coupling them. Global middleware is configured in `src/start.ts` using `createStart()`.
## Bad Example
```tsx
// Monolithic middleware doing too many things
const doEverythingMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next, request }) => {
// Logging
const start = Date.now()
console.log(`${request.method} ${request.url}`)
// Auth
const session = await useAppSession()
if (!session.data.userId) throw redirect({ to: '/login' })
const user = await db.users.findUnique({ where: { id: session.data.userId } })
// Permissions
const perms = await getPermissions(user.id)
// Rate limiting
const ip = request.headers.get('x-forwarded-for')
if (await isRateLimited(ip)) throw new Error('Rate limited')
console.log(`Completed in ${Date.now() - start}ms`)
return next({ context: { user, perms } })
})
```
## Good Example: Composable Middleware Chain
```tsx
import { createMiddleware } from '@tanstack/react-start'
// Each middleware has a single responsibility
const loggingMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const start = Date.now()
const result = await next()
console.log(`Completed in ${Date.now() - start}ms`)
return result
})
const authMiddleware = createMiddleware({ type: 'function' })
.middleware([loggingMiddleware]) // Logging wraps auth
.server(async ({ next }) => {
const session = await useAppSession()
if (!session.data.userId) throw redirect({ to: '/login' })
const user = await db.users.findUnique({
where: { id: session.data.userId },
})
return next({ context: { user } })
})
const adminMiddleware = createMiddleware({ type: 'function' })
.middleware([authMiddleware]) // Auth wraps admin check
.server(async ({ next, context }) => {
if (context.user.role !== 'admin') {
throw redirect({ to: '/unauthorized' })
}
return next()
})
// Use the composed chain — logging + auth + admin all applied
export const deleteUser = createServerFn({ method: 'POST' })
.middleware([adminMiddleware])
.inputValidator(z.object({ userId: z.string() }))
.handler(async ({ data, context }) => {
await db.users.delete({ where: { id: data.userId } })
})
```
## Good Example: Global Middleware in start.ts
```tsx
// src/start.ts
import { createStart, createMiddleware } from '@tanstack/react-start'
const requestLogger = createMiddleware().server(async ({ next, request }) => {
console.log(`${request.method} ${request.url}`)
return next()
})
const functionLogger = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const start = Date.now()
const result = await next()
console.log(`Server fn completed in ${Date.now() - start}ms`)
return result
})
export const startInstance = createStart(() => ({
// Runs on ALL server requests (SSR, server routes, server functions)
requestMiddleware: [requestLogger],
// Runs on ALL server function calls
functionMiddleware: [functionLogger],
}))
```
## Good Example: Middleware for Server Routes
```tsx
// Middleware on all handlers in a server route
export const Route = createFileRoute('/api/users')({
server: {
middleware: [authMiddleware], // Applies to GET and POST
handlers: {
GET: async ({ context }) => {
return Response.json(await db.users.findMany())
},
POST: async ({ request, context }) => {
const body = await request.json()
return Response.json(await db.users.create({ data: body }))
},
},
},
})
// Middleware on specific handlers only
export const Route = createFileRoute('/api/posts')({
server: {
handlers: ({ createHandlers }) =>
createHandlers({
GET: async ({ request }) => {
return Response.json(await db.posts.findMany()) // Public
},
POST: {
middleware: [authMiddleware], // Only POST requires auth
handler: async ({ request, context }) => {
const body = await request.json()
return Response.json(await db.posts.create({ data: body }))
},
},
}),
},
})
```
## Context
- `.middleware([...])` on `createMiddleware` creates a dependency chain
- Execution order: outermost middleware wraps inner middleware wraps handler
- Global middleware in `src/start.ts`: `requestMiddleware` (all requests) and `functionMiddleware` (server functions)
- Route-level server middleware via `server.middleware` applies to all handlers
- Per-handler middleware via `createHandlers` applies to specific HTTP methods
- Keep each middleware focused on a single concern for reusability
rules/mw-context-flow.md
# mw-context-flow: Properly Pass Context Through Middleware
## Priority: HIGH
## Explanation
Middleware communicates with downstream handlers and other middleware through context. Use `next({ context: {...} })` to pass data down the chain. Client-to-server context requires explicit `sendContext`. Context is fully type-safe — TypeScript infers what's available in each handler.
## Bad Example
```tsx
// Using global state instead of context
let currentUser: User | null = null
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
currentUser = await getUser() // Global mutable state - race conditions!
return next()
})
export const getProfile = createServerFn()
.middleware([authMiddleware])
.handler(async () => {
return currentUser // May be wrong user in concurrent requests
})
```
## Good Example: Server Context
```tsx
import { createMiddleware, createServerFn } from '@tanstack/react-start'
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const user = await getCurrentUser()
// Pass through context — type-safe and request-scoped
return next({
context: { user },
})
})
const permissionMiddleware = createMiddleware({ type: 'function' })
.middleware([authMiddleware])
.server(async ({ next, context }) => {
// context.user is available from authMiddleware
const permissions = await getPermissions(context.user.id)
return next({
context: { permissions }, // Adds to existing context
})
})
export const getAdminData = createServerFn()
.middleware([permissionMiddleware])
.handler(async ({ context }) => {
// Both user and permissions are available and typed
if (!context.permissions.includes('admin')) {
throw new Error('Forbidden')
}
return fetchAdminData()
})
```
## Good Example: Client-to-Server Context with sendContext
```tsx
// Client context is NOT sent to server by default — use sendContext
const workspaceMiddleware = createMiddleware({ type: 'function' })
.client(async ({ next, context }) => {
// Send specific data from client to server
return next({
sendContext: {
workspaceId: context.workspaceId,
locale: navigator.language,
},
})
})
.server(async ({ next, context }) => {
// context.workspaceId is available here from sendContext
console.log('Workspace:', context.workspaceId)
console.log('Locale:', context.locale)
return next()
})
```
## Good Example: Server-to-Client Context with sendContext
```tsx
const timerMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const start = Date.now()
const result = await next({
sendContext: {
serverTimestamp: new Date().toISOString(),
},
})
return result
})
.client(async ({ next, context }) => {
const result = await next()
// context.serverTimestamp is available after server responds
console.log('Server time:', context.serverTimestamp)
return result
})
```
## Context
- `next({ context: {...} })` passes data to downstream middleware/handlers (server-side only)
- `sendContext` is needed to cross the client/server boundary in either direction
- Client context is NOT sent to the server by default — only `sendContext` fields are transmitted
- `sendContext` data is type-safe but NOT runtime-validated on the server — validate if it contains user input
- Context accumulates through the middleware chain — each middleware can add to it
- Context is request-scoped, avoiding race conditions from global state
rules/mw-function-middleware.md
# mw-function-middleware: Use Function Middleware for Server Functions
## Priority: HIGH
## Explanation
Server function middleware (`type: 'function'`) runs specifically around server function calls and supports both client-side and server-side hooks. Unlike request middleware, function middleware can intercept calls on the client before they reach the server, add custom headers, validate inputs, and transform responses.
## Bad Example
```tsx
// Using request middleware for function-specific concerns
// This runs on ALL requests, not just the server functions that need it
const loggingMiddleware = createMiddleware()
.server(async ({ next }) => {
console.log('Request started')
return next()
})
// Or duplicating logic in every handler
export const getData = createServerFn()
.handler(async () => {
console.log('Function called') // Repeated everywhere
const start = Date.now()
const result = await fetchData()
console.log(`Took ${Date.now() - start}ms`)
return result
})
```
## Good Example
```tsx
import { createMiddleware, createServerFn } from '@tanstack/react-start'
// Function middleware with both client and server hooks
const loggingMiddleware = createMiddleware({ type: 'function' })
.client(async ({ next }) => {
console.log('Client: calling server function')
const start = Date.now()
const result = await next()
console.log(`Client: response in ${Date.now() - start}ms`)
return result
})
.server(async ({ next }) => {
console.log('Server: handling function call')
const result = await next()
console.log('Server: function complete')
return result
})
export const getData = createServerFn()
.middleware([loggingMiddleware])
.handler(async () => {
return fetchData()
})
```
## Good Example: Auth Token Middleware
```tsx
// Attach auth headers from client-side token
const authTokenMiddleware = createMiddleware({ type: 'function' })
.client(async ({ next }) => {
const token = getAuthToken() // From client-side auth provider
return next({
headers: {
Authorization: `Bearer ${token}`,
},
})
})
.server(async ({ next }) => {
const authHeader = getRequestHeader('Authorization')
const user = await verifyToken(authHeader)
return next({ context: { user } })
})
export const getProfile = createServerFn()
.middleware([authTokenMiddleware])
.handler(async ({ context }) => {
return db.users.findUnique({ where: { id: context.user.id } })
})
```
## Good Example: Input Validation in Middleware
```tsx
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
// Middleware that validates common input patterns
const workspaceMiddleware = createMiddleware({ type: 'function' })
.inputValidator(zodValidator(z.object({
workspaceId: z.string().uuid(),
})))
.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 } })
})
export const getWorkspaceMembers = createServerFn()
.middleware([workspaceMiddleware])
.handler(async ({ context }) => {
return db.members.findMany({
where: { workspaceId: context.workspace.id },
})
})
```
## Context
- `type: 'function'` distinguishes function middleware from request middleware
- `.client()` runs on the client before the RPC call — use for headers, tokens, logging
- `.server()` runs on the server when handling the call — use for auth, context, validation
- `.inputValidator()` is only available on function middleware, not request middleware
- Use `@tanstack/zod-adapter`'s `zodValidator()` for middleware input validation
- Function middleware can send custom headers via `next({ headers: {...} })`
- Request middleware (`createMiddleware()` without `type`) runs on ALL server requests
rules/mw-request-middleware.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// src/start.ts
import { createStart } from '@tanstack/react-start'
import { loggingMiddleware } from './middleware/logging'
import { authMiddleware } from './middleware/auth'
export const startInstance = createStart(() => ({
// Request middleware runs for all requests
requestMiddleware: [
loggingMiddleware,
authMiddleware,
],
}))
```
## Good Example: Rate Limiting Middleware
```tsx
// 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 `src/start.ts`
- Route-specific middleware uses `beforeLoad`
- Server function middleware uses separate pattern (see `mw-function-middleware`)
rules/sec-auth-middleware.md
# sec-auth-middleware: Protect Routes with Auth Middleware
## Priority: CRITICAL
## Explanation
Use middleware to enforce authentication and authorization consistently across routes and server functions. Centralizing auth checks in middleware prevents accidentally leaving endpoints unprotected.
## Bad Example
```tsx
// Checking auth in every handler - easy to forget one
export const getProfile = createServerFn()
.handler(async () => {
const session = await useAppSession()
if (!session.data.userId) throw redirect({ to: '/login' })
return db.users.findUnique({ where: { id: session.data.userId } })
})
export const updateProfile = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
const session = await useAppSession()
if (!session.data.userId) throw redirect({ to: '/login' })
// ... duplicate check in every function
})
export const getSettings = createServerFn()
.handler(async () => {
// Forgot the auth check here! Endpoint is unprotected
return db.settings.findFirst()
})
```
## Good Example: Auth Middleware for Server Functions
```tsx
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { useSession } from '@tanstack/react-start/server'
import { redirect } from '@tanstack/react-router'
// Reusable auth middleware
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const session = await useAppSession()
if (!session.data.userId) {
throw redirect({ to: '/login' })
}
const user = await db.users.findUnique({
where: { id: session.data.userId },
})
if (!user) {
throw redirect({ to: '/login' })
}
return next({
context: { user },
})
})
// All protected functions use the middleware - auth is guaranteed
export const getProfile = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => {
return context.user // User is guaranteed to exist
})
export const updateProfile = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(updateProfileSchema)
.handler(async ({ data, context }) => {
return db.users.update({
where: { id: context.user.id },
data,
})
})
```
## Good Example: Route Protection with beforeLoad
```tsx
// routes/_authed.tsx — pathless layout route protects all children
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
const user = await getCurrentUser()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { user }
},
})
// routes/_authed/dashboard.tsx — automatically protected
export const Route = createFileRoute('/_authed/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const { user } = Route.useRouteContext()
return <h1>Welcome, {user.email}!</h1>
}
```
## Good Example: Role-Based Middleware
```tsx
const adminMiddleware = createMiddleware({ type: 'function' })
.middleware([authMiddleware]) // Chains on auth middleware
.server(async ({ next, context }) => {
if (context.user.role !== 'admin') {
throw redirect({ to: '/unauthorized' })
}
return next()
})
export const deleteUser = createServerFn({ method: 'POST' })
.middleware([adminMiddleware])
.inputValidator(z.object({ userId: z.string() }))
.handler(async ({ data }) => {
await db.users.delete({ where: { id: data.userId } })
})
```
## Context
- Use `createMiddleware({ type: 'function' })` for server function middleware
- Use `beforeLoad` on pathless layout routes (e.g., `_authed.tsx`) for route protection
- Middleware can compose: admin middleware can chain on auth middleware
- Context from middleware is type-safe in handlers
- `beforeLoad` runs serially (parent before children) so parent auth protects all children
rules/sec-csrf-protection.md
# sec-csrf-protection: Implement CSRF Protection for Mutations
## Priority: CRITICAL
## Explanation
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into making unintended requests. TanStack Start server functions use RPC calls (not plain form submissions), which provides some inherent protection. Cookie-based sessions should use `sameSite` settings for additional CSRF defense.
## Bad Example
```tsx
// No sameSite on session cookie - vulnerable to CSRF
export function useAppSession() {
return useSession({
password: process.env.SESSION_SECRET!,
cookie: {
httpOnly: true,
secure: true,
// Missing sameSite! Browser sends cookie on cross-site requests
},
})
}
// GET server function that mutates data - can be triggered by img tags
export const deleteAccount = createServerFn() // GET is default
.handler(async () => {
const session = await useAppSession()
await db.users.delete({ where: { id: session.data.userId } })
})
```
## Good Example
```tsx
import { useSession } from '@tanstack/react-start/server'
// Configure session cookies with sameSite protection
export function useAppSession() {
return useSession<SessionData>({
password: process.env.SESSION_SECRET!, // At least 32 characters
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // Blocks cross-site POST requests
maxAge: 60 * 60 * 24 * 7,
},
})
}
// Use POST for all mutations - never GET
export const deleteAccount = createServerFn({ method: 'POST' })
.handler(async () => {
const session = await useAppSession()
if (!session.data.userId) {
throw redirect({ to: '/login' })
}
await db.users.delete({ where: { id: session.data.userId } })
await session.clear()
throw redirect({ to: '/' })
})
```
## Good Example: OAuth State Parameter for CSRF in Auth Flows
```tsx
export const startOAuth = createServerFn({ method: 'POST' })
.handler(async () => {
const session = await useAppSession()
const state = crypto.randomUUID()
await session.update({ oauthState: state })
const authUrl = new URL('https://provider.com/oauth/authorize')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID!)
authUrl.searchParams.set('redirect_uri', process.env.OAUTH_CALLBACK_URL!)
throw redirect({ href: authUrl.toString() })
})
export const handleOAuthCallback = createServerFn()
.inputValidator((data: { code: string; state: string }) => data)
.handler(async ({ data }) => {
const session = await useAppSession()
// Verify state to prevent CSRF
if (data.state !== session.data.oauthState) {
throw new Error('Invalid OAuth state - possible CSRF attack')
}
// Exchange code for token...
const token = await exchangeCodeForToken(data.code)
await session.update({ userId: token.userId, oauthState: undefined })
throw redirect({ to: '/dashboard' })
})
```
## Context
- `sameSite: 'lax'` — cookies sent on top-level navigations but not cross-site POST/AJAX
- `sameSite: 'strict'` — cookies never sent on cross-site requests (may break OAuth flows)
- Server functions use RPC (fetch + JSON), not plain form submissions, adding inherent CSRF resistance
- Always use `POST` for state-changing operations
- For OAuth flows, always use a random `state` parameter stored in the session
- The combination of `sameSite: 'lax'` + POST-only mutations provides strong CSRF protection
rules/sec-sensitive-data.md
# sec-sensitive-data: Keep Secrets Server-Side Only
## Priority: CRITICAL
## Explanation
Route loaders are isomorphic — they run on both the server (SSR) and client (navigation). Code in loaders is included in the client bundle. Never access secrets, database connections, or private API keys in loaders. Use `createServerFn()` to keep sensitive logic server-only.
## Bad Example
```tsx
// DANGEROUS: loader runs on both server and client
// process.env.SECRET is bundled into client JavaScript
export const Route = createFileRoute('/users')({
loader: () => {
const secret = process.env.DATABASE_URL // EXPOSED in client bundle!
return fetch(`${process.env.API_URL}/users`, {
headers: { Authorization: `Bearer ${process.env.API_SECRET}` },
})
},
})
// DANGEROUS: importing server modules in route files
import { db } from './db.server' // Database driver bundled into client!
export const Route = createFileRoute('/posts')({
loader: () => db.posts.findMany(),
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
// Server function - code only runs on server, replaced with RPC stub on client
const getUsers = createServerFn().handler(async () => {
// Safe: this code never reaches the client bundle
const secret = process.env.API_SECRET
const response = await fetch(`${process.env.API_URL}/users`, {
headers: { Authorization: `Bearer ${secret}` },
})
return response.json()
})
export const Route = createFileRoute('/users')({
loader: () => getUsers(), // Client calls this as an RPC
})
```
## Good Example: Environment Variable Boundaries
```tsx
// Client-safe: only VITE_ prefixed vars are available
function AppHeader() {
return <h1>{import.meta.env.VITE_APP_NAME}</h1>
}
// Server-only: access any env var inside server functions
const getConfig = createServerFn().handler(async () => {
return {
// Only return what the client needs — never return secrets
appName: process.env.VITE_APP_NAME,
featureFlags: JSON.parse(process.env.FEATURE_FLAGS ?? '{}'),
}
})
// For truly server-only utility functions
import { createServerOnlyFn } from '@tanstack/react-start'
const getDbConnection = createServerOnlyFn(() => {
return connect(process.env.DATABASE_URL)
})
// Calling getDbConnection() on the client throws an error
```
## Good Example: File Organization for Server/Client Separation
```
src/utils/
users.functions.ts # createServerFn wrappers — safe to import anywhere
users.server.ts # Direct DB access, internal logic — server-only
schemas.ts # Zod schemas, types — safe for both
```
```tsx
// users.functions.ts — safe to import in components
import { createServerFn } from '@tanstack/react-start'
export const getUsers = createServerFn().handler(async () => {
// Import server-only code inside the handler
const { db } = await import('./users.server')
return db.users.findMany()
})
```
## Context
- Route `loader`, `beforeLoad`, and `component` code is isomorphic (runs on server AND client)
- `createServerFn()` handlers only run on the server — client gets an RPC stub
- `createServerOnlyFn(fn)` throws if accidentally called on the client
- `createClientOnlyFn(fn)` throws if accidentally called on the server
- Only `VITE_`-prefixed environment variables are available on the client via `import.meta.env`
- All `process.env` variables are available inside server functions
- Static imports of server function files are safe — the bundler handles code splitting
rules/sec-validate-inputs.md
# sec-validate-inputs: Validate All User Inputs with Schemas
## Priority: CRITICAL
## Explanation
All data crossing the client/server boundary must be validated. Server functions accept arbitrary input from the network — never trust it. Use `.inputValidator()` with schema libraries like Zod to validate and type inputs before processing.
## Bad Example
```tsx
// No validation - trusting client input directly
export const updateUser = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
// data is untyped and unvalidated
await db.users.update({
where: { id: data.id },
data: { role: data.role }, // Could set role to 'admin'!
})
})
// Type-only validation - no runtime checks
export const createPost = createServerFn({ method: 'POST' })
.inputValidator((data: { title: string; content: string }) => data)
.handler(async ({ data }) => {
// TypeScript types don't exist at runtime
// data.title could be a number, null, or a 10MB string
await db.posts.create({ data })
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
// Zod schema provides runtime validation
const updateUserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email().max(255),
// Explicitly list allowed fields - no role, no isAdmin
})
export const updateUser = createServerFn({ method: 'POST' })
.inputValidator(updateUserSchema)
.handler(async ({ data }) => {
// data is validated and typed: { id: string, name: string, email: string }
return db.users.update({
where: { id: data.id },
data: { name: data.name, email: data.email },
})
})
// Validate with constraints
const createPostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
tags: z.array(z.string().max(50)).max(10).optional(),
})
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data }) => {
return db.posts.create({ data })
})
```
## Good Example: FormData Validation
```tsx
export const submitForm = createServerFn({ method: 'POST' })
.inputValidator((data) => {
if (!(data instanceof FormData)) {
throw new Error('Expected FormData')
}
// Parse and validate FormData fields
const parsed = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
message: z.string().min(1).max(5000),
}).parse({
name: data.get('name'),
email: data.get('email'),
message: data.get('message'),
})
return parsed
})
.handler(async ({ data }) => {
await sendContactEmail(data)
return { success: true }
})
```
## Context
- `.inputValidator()` runs before the handler — invalid data never reaches your logic
- Zod schemas can be passed directly to `.inputValidator()`
- Custom validator functions work too: `(data) => validatedData`
- Validation errors are automatically serialized to the client
- Always constrain string lengths to prevent abuse
- Explicitly list allowed fields — never spread raw input into database queries
- Share schemas between client and server for consistent validation (see `file-shared-validation`)
rules/sf-create-server-fn.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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()
.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
})
// 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
```tsx
// 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
rules/sf-error-handling.md
# sf-error-handling: Handle Errors in Server Functions
## Priority: CRITICAL
## Explanation
Server functions can throw `redirect()` and `notFound()` from `@tanstack/react-router` for control flow, and regular errors for unexpected failures. These are automatically serialized across the server/client boundary. Proper error handling prevents unhandled rejections and provides good UX.
## Bad Example
```tsx
// Returning error objects instead of throwing - loses router integration
export const getPost = createServerFn()
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } })
if (!post) {
return { error: 'Not found' } // Client must check manually
}
return { data: post }
})
// Throwing generic errors instead of redirect
export const requireAuth = createServerFn()
.handler(async () => {
const user = await getCurrentUser()
if (!user) {
throw new Error('Not authenticated') // No redirect, bad UX
}
return user
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
import { redirect, notFound } from '@tanstack/react-router'
// Use notFound() for missing resources - triggers notFoundComponent
export const getPost = createServerFn()
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const post = await db.posts.findUnique({ where: { id: data.id } })
if (!post) {
throw notFound()
}
return post
})
// Use redirect() for auth failures - navigates to login
export const requireAuth = createServerFn()
.handler(async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
return user
})
// Use redirect() with search params to preserve return URL
export const requireAuthWithReturn = createServerFn()
.handler(async () => {
const request = getRequest()
const user = await getCurrentUser()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: new URL(request.url).pathname },
})
}
return user
})
```
## Good Example: Error Handling with isRedirect
```tsx
import { redirect, isRedirect } from '@tanstack/react-router'
export const performAction = createServerFn({ method: 'POST' })
.inputValidator((data: { action: string }) => data)
.handler(async ({ data }) => {
try {
const result = await riskyOperation(data.action)
return result
} catch (error) {
// Re-throw redirects and notFound - they are control flow, not errors
if (isRedirect(error)) throw error
// Handle actual errors
console.error('Action failed:', error)
throw new Error('Failed to perform action')
}
})
```
## Context
- `throw redirect()` triggers client-side navigation
- `throw notFound()` triggers the nearest `notFoundComponent`
- Regular thrown errors trigger the nearest `errorComponent`
- Use `isRedirect()` to distinguish redirects from errors in try/catch blocks
- Validation errors from `.inputValidator()` are automatically serialized
- All imports: `redirect`, `notFound`, `isRedirect` from `@tanstack/react-router`
rules/sf-input-validation.md
# 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
```tsx
// 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
```tsx
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' })
.inputValidator(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 messages
```
## Good Example: Complex Validation
```tsx
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' })
.inputValidator(createOrderSchema)
.handler(async ({ data }) => {
// All data is validated and typed
// Process order safely
})
```
## Good Example: Transform and Refine
```tsx
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' })
.inputValidator(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
```tsx
// 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' })
.inputValidator(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 `.inputValidator()` 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
rules/sf-method-selection.md
# sf-method-selection: Choose Appropriate HTTP Method
## Priority: CRITICAL
## Explanation
Server functions support `GET` (default) and `POST` methods. Use `GET` for data fetching (idempotent, cacheable) and `POST` for mutations that change data. Choosing the wrong method can cause caching issues, duplicate mutations, or unexpected behavior during prefetching.
## Bad Example
```tsx
// Using POST for a read operation - prevents caching and prefetching
export const getUsers = createServerFn({ method: 'POST' })
.handler(async () => {
return db.users.findMany()
})
// Using GET for a mutation - can be replayed by browser/cache
export const deleteUser = createServerFn({ method: 'GET' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
await db.users.delete({ where: { id: data.id } })
return { success: true }
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
// GET for reads - idempotent, cacheable, safe for prefetching
export const getUsers = createServerFn() // GET is default
.handler(async () => {
return db.users.findMany({ orderBy: { createdAt: 'desc' } })
})
export const getUser = createServerFn()
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
return db.users.findUnique({ where: { id: data.id } })
})
// POST for mutations - not cached, not replayed
export const createUser = createServerFn({ method: 'POST' })
.inputValidator((data: { name: string; email: string }) => data)
.handler(async ({ data }) => {
return db.users.create({ data })
})
export const updateUser = createServerFn({ method: 'POST' })
.inputValidator((data: { id: string; name: string }) => data)
.handler(async ({ data }) => {
return db.users.update({
where: { id: data.id },
data: { name: data.name },
})
})
export const deleteUser = createServerFn({ method: 'POST' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
await db.users.delete({ where: { id: data.id } })
return { success: true }
})
```
## Context
- `GET` is the default when no method is specified
- `GET` functions can be called during route preloading and prefetching
- `POST` functions are never automatically called by the router
- Use `GET` for route loaders, `POST` for form submissions and mutations
- Both methods support `.inputValidator()` and `.middleware()`
rules/sf-response-headers.md
# sf-response-headers: Customize Response Headers When Needed
## Priority: MEDIUM
## Explanation
Server functions can read request headers and set response headers using utilities from `@tanstack/react-start/server`. This is useful for caching, authentication, CORS, and other HTTP-level concerns.
## Bad Example
```tsx
// Trying to access request/response directly - not available in server functions
export const getCachedData = createServerFn()
.handler(async () => {
// req and res don't exist in server function context
const authHeader = req.headers.authorization
res.setHeader('Cache-Control', 'max-age=300')
return fetchData()
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
import {
getRequest,
getRequestHeader,
setResponseHeader,
setResponseHeaders,
setResponseStatus,
} from '@tanstack/react-start/server'
// Reading request headers
export const getProtectedData = createServerFn()
.handler(async () => {
const authHeader = getRequestHeader('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
setResponseStatus(401)
throw new Error('Unauthorized')
}
return fetchProtectedData(authHeader)
})
// Setting cache headers
export const getCachedData = createServerFn()
.handler(async () => {
setResponseHeaders(new Headers({
'Cache-Control': 'public, max-age=300',
'CDN-Cache-Control': 'max-age=3600, stale-while-revalidate=600',
}))
return fetchData()
})
// Setting individual headers
export const downloadFile = createServerFn()
.inputValidator((data: { filename: string }) => data)
.handler(async ({ data }) => {
setResponseHeader('Content-Disposition', `attachment; filename="${data.filename}"`)
setResponseHeader('Content-Type', 'application/octet-stream')
return getFileContents(data.filename)
})
// Accessing the full request object
export const handleWebhook = createServerFn({ method: 'POST' })
.handler(async () => {
const request = getRequest()
const signature = request.headers.get('x-webhook-signature')
const body = await request.text()
if (!verifySignature(body, signature)) {
setResponseStatus(403)
throw new Error('Invalid signature')
}
await processWebhook(JSON.parse(body))
return { received: true }
})
```
## Context
- All utilities imported from `@tanstack/react-start/server`
- `getRequest()` returns the full `Request` object
- `getRequestHeader(name)` returns a specific request header
- `setResponseHeader(name, value)` sets a single response header
- `setResponseHeaders(headers)` sets multiple headers via a `Headers` object
- `setResponseStatus(code)` sets the HTTP status code
- These utilities use async context — they work anywhere inside a server function handler
rules/ssr-data-loading.md
# ssr-data-loading: Load Data Appropriately for SSR
## Priority: MEDIUM
## Explanation
Route loaders are isomorphic — they run on the server during SSR and on the client during navigation. Use server functions inside loaders for data that requires server-side access (databases, secrets). Understand the loading lifecycle to avoid common SSR pitfalls.
## Bad Example
```tsx
// Loader accessing server-only resources directly - breaks on client
export const Route = createFileRoute('/posts')({
loader: async () => {
// db import gets bundled into client code!
const posts = await db.posts.findMany()
return posts
},
})
// Returning the entire search object from loaderDeps
export const Route = createFileRoute('/posts')({
loaderDeps: ({ search }) => search, // Reloads on ANY search change
loader: async ({ deps }) => {
return fetchPosts(deps)
},
})
// Calling browser APIs during SSR
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const width = window.innerWidth // Crashes on server!
return fetchLayout(width)
},
})
```
## Good Example
```tsx
import { createServerFn } from '@tanstack/react-start'
import { createFileRoute } from '@tanstack/react-router'
// Server function handles server-only logic
const getPosts = createServerFn()
.inputValidator((data: { offset: number; limit: number }) => data)
.handler(async ({ data }) => {
return db.posts.findMany({
skip: data.offset,
take: data.limit,
orderBy: { createdAt: 'desc' },
})
})
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
offset: z.number().default(0),
limit: z.number().default(20),
}),
// Only extract the deps the loader actually uses
loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }),
loader: async ({ deps }) => {
return getPosts({ data: deps })
},
})
```
## Good Example: Using beforeLoad for Auth Context
```tsx
// beforeLoad runs serially (parent → child), before loaders
// Use it for auth checks that gate data loading
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
const user = await getCurrentUser()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { user } // Available to child routes via context
},
})
// Child route can use context from parent's beforeLoad
export const Route = createFileRoute('/_authed/dashboard')({
loader: async ({ context }) => {
// context.user is guaranteed by parent's beforeLoad
return getDashboardData({ data: { userId: context.user.id } })
},
})
```
## Loading Lifecycle
```
1. Route Matching (top-down): params.parse → validateSearch
2. Pre-Loading (serial): beforeLoad → onError
3. Loading (parallel): component.preload? + loader
```
## Context
- Loaders run on both server (SSR) and client (navigation) — they are NOT server-only
- Use `createServerFn()` inside loaders to access databases, secrets, and server resources
- `loaderDeps` controls when the loader re-runs — only extract what's needed
- `beforeLoad` runs before `loader` and is serial (parent → child)
- Loader results are serialized and dehydrated during SSR, then hydrated on the client
- Default `staleTime` is `0` (always reload on navigation); `preloadStaleTime` is `30` seconds
- Use `router.invalidate()` to force all active loaders to re-run
rules/ssr-hydration-safety.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
rules/ssr-prerender.md
# 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
```tsx
// 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
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
autoStaticPathsDiscovery: true,
concurrency: 14,
filter: ({ path }) => !path.startsWith('/app'),
},
}),
viteReact(),
],
})
```
```tsx
// 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: Per-Page Prerender Config
```ts
// vite.config.ts
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
},
pages: [
{
path: '/landing',
prerender: { enabled: true, outputPath: '/landing/index.html' },
},
],
})
```
## Good Example: ISR with Cache Headers
```tsx
// routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => fetchPost(params.slug),
// ISR via standard HTTP cache headers
headers: () => ({
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
}),
staleTime: 60_000, // Client-side stale time (1 minute)
gcTime: 5 * 60_000, // Client-side GC time (5 minutes)
component: BlogPost,
})
// First request: SSR and cache at CDN
// Next 3600 seconds: Serve cached version
// After 3600 seconds: Serve stale, revalidate in background
// After 86400 seconds: Full SSR again
```
## Good Example: Hybrid Static/Dynamic
```tsx
// 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()
return { product }
},
// Cache product pages for 5 minutes
headers: () => ({
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
}),
})
// routes/cart.tsx - 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',
}),
})
```
## Good Example: On-Demand Revalidation via Server Route
```tsx
// routes/api/revalidate.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/revalidate')({
server: {
handlers: {
POST: async ({ request }) => {
const { secret, path } = await request.json()
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 })
}
// Trigger revalidation (implementation depends on hosting)
await revalidatePath(path)
return Response.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
rules/ssr-selective.md
# ssr-selective: Apply Selective SSR When Beneficial
## Priority: MEDIUM
## Explanation
Not every route needs full server-side rendering. TanStack Start provides per-route SSR control via the `ssr` option: `true` (default, full SSR), `false` (client-only rendering), or `'data-only'` (server data loading but client rendering). This optimizes server load while preserving SEO and initial load performance where needed.
## Bad Example
```tsx
// SSR for a heavy interactive dashboard - wastes server resources
// and the server-rendered HTML is immediately replaced by client state
export const Route = createFileRoute('/app/dashboard')({
// ssr: true is default - server renders the full component tree
loader: () => getDashboardData(),
component: HeavyInteractiveDashboard,
})
// No SSR for a marketing landing page - bad for SEO
export const Route = createFileRoute('/')({
ssr: false, // Google sees a blank page
component: LandingPage,
})
```
## Good Example
```tsx
import { createFileRoute } from '@tanstack/react-router'
// Full SSR for public/SEO-critical pages (default)
export const Route = createFileRoute('/blog/$slug')({
// ssr: true is default — full server rendering
loader: ({ params }) => getPost({ data: { slug: params.slug } }),
component: BlogPost,
})
// Client-only for heavy interactive apps — no server rendering overhead
export const Route = createFileRoute('/app/dashboard')({
ssr: false, // No beforeLoad/loader on server, no server rendering
loader: () => getDashboardData(),
component: Dashboard,
})
// Data-only for pages where data matters but rendering doesn't
export const Route = createFileRoute('/app/settings')({
ssr: 'data-only', // Runs loader on server, renders on client only
loader: () => getUserSettings(),
component: SettingsPage,
})
```
## Good Example: Dynamic SSR with Function Form
```tsx
import { z } from 'zod'
export const Route = createFileRoute('/docs/$docType/$docId')({
validateSearch: z.object({ details: z.boolean().optional() }),
ssr: ({ params, search }) => {
// Disable SSR for heavy document types
if (params.status === 'success' && params.value.docType === 'spreadsheet') {
return false
}
// Data-only for detail views
if (search.status === 'success' && search.value.details) {
return 'data-only'
}
// Default: full SSR
},
loader: ({ params }) => getDocument({ data: params }),
component: DocumentViewer,
})
```
## Good Example: Global Default and Root Shell
```tsx
// src/start.ts — disable SSR globally for a SPA-like app
import { createStart } from '@tanstack/react-start'
export const startInstance = createStart(() => ({
defaultSsr: false,
}))
// When root route has ssr: false, you need a shellComponent
// routes/__root.tsx
export const Route = createRootRoute({
ssr: false,
shellComponent: RootShell,
component: RootComponent,
})
function RootShell({ children }: { children: React.ReactNode }) {
return (
<html>
<head><HeadContent /></head>
<body>{children}<Scripts /></body>
</html>
)
}
```
## SSR Modes Reference
| Mode | `beforeLoad`/`loader` on server | Server renders component | Use case |
|------|------|------|------|
| `true` | Yes | Yes | SEO pages, landing pages, blogs |
| `'data-only'` | Yes | No | Data-heavy apps, authenticated pages |
| `false` | No | No | Interactive dashboards, admin panels |
## Context
- Child routes inherit parent SSR settings and can only make them MORE restrictive
- Inheritance hierarchy: `true` → `'data-only'` → `false` (child can only go right)
- When `ssr: false` on the root route, provide a `shellComponent` for the HTML shell
- The `ssr` function form receives `params` and `search` for dynamic decisions
- Global default SSR is set in `src/start.ts` via `createStart(() => ({ defaultSsr }))`
- `ssr: false` routes still work with server functions — they just don't SSR the component
rules/ssr-streaming.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
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
```tsx
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
```tsx
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 progressively
```
## Context
- 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
SKILL.md
---
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 | 1 rule | External endpoint patterns |
| MEDIUM | SSR | 5 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 logic
- `sf-input-validation` — Always validate server function inputs
- `sf-method-selection` — Choose appropriate HTTP method
- `sf-error-handling` — Handle errors in server functions
- `sf-response-headers` — Customize response headers when needed
### Security (Prefix: `sec-`)
- `sec-validate-inputs` — Validate all user inputs with schemas
- `sec-auth-middleware` — Protect routes with auth middleware
- `sec-sensitive-data` — Keep secrets server-side only
- `sec-csrf-protection` — Implement CSRF protection for mutations
### Middleware (Prefix: `mw-`)
- `mw-request-middleware` — Use request middleware for cross-cutting concerns
- `mw-function-middleware` — Use function middleware for server functions
- `mw-context-flow` — Properly pass context through middleware
- `mw-composability` — Compose middleware effectively
### Authentication (Prefix: `auth-`)
- `auth-session-management` — Implement secure session handling
- `auth-route-protection` — Protect routes with beforeLoad
- `auth-server-functions` — Verify auth in server functions
- `auth-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 SSR
- `ssr-hydration-safety` — Prevent hydration mismatches
- `ssr-streaming` — Implement streaming SSR for faster TTFB
- `ssr-selective` — Apply selective SSR when beneficial
- `ssr-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 errors
- `err-redirects` — Use redirects appropriately
- `err-not-found` — Handle not-found scenarios
### File Organization (Prefix: `file-`)
- `file-separation` — Separate server and client code
- `file-functions-file` — Use .functions.ts pattern
- `file-shared-validation` — Share validation schemas
### Deployment (Prefix: `deploy-`)
- `deploy-env-config` — Configure environment variables
- `deploy-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.