Backend Rules
Every API route must pass this checklist before it ships. These rules are drawn from post-incident reviews on production systems.
The Non-Negotiable Order
Every route handler must execute in this order, without exception:
- Rate limit check — before any auth or business logic
- Auth verification —
getUser(), nevergetSession()alone - Input validation — Zod schema before touching the database
- Authorization — check ownership/permission, not just authentication
- Business logic — only if all above pass
- Response — structured, never leaking internals
(BLOCKING — any deviation requires explicit justification in PR)
Zod Input Validation in API Routes
// app/api/orders/route.ts
import { z } from 'zod'
import { createServerClient } from '@supabase/ssr'
import { NextRequest } from 'next/server'
const createOrderSchema = z.object({
branchId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
})).min(1),
notes: z.string().max(500).optional(),
})
export async function POST(request: NextRequest) {
// 1. Rate limit (see section below)
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success: rateLimitOk } = await orderRatelimit.limit(ip)
if (!rateLimitOk) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
// 2. Auth
const supabase = createServerClient(/* cookies */)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (!user || authError) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// 3. Input validation
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = createOrderSchema.safeParse(body)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
const input = result.data
// 4. Authorization — verify user has access to this branch
const { data: membership } = await supabase
.from('branch_members')
.select('role')
.eq('branch_id', input.branchId)
.eq('user_id', user.id)
.single()
if (!membership) {
return Response.json({ error: 'Not found' }, { status: 404 })
// Return 404, not 403 — don't reveal resource existence
}
// 5. Business logic
try {
const order = await createOrder(input, user.id)
return Response.json(order, { status: 201 })
} catch (err) {
// 6. Error handling — log full error server-side, return generic to client
console.error('[POST /api/orders]', err)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}
Supabase auth.getUser() Pattern
// lib/auth.ts — shared helper for all route handlers
import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'
export async function getAuthenticatedUser() {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
const { data: { user }, error } = await supabase.auth.getUser()
return { user, error, supabase }
}
// Usage in any route:
export async function GET() {
const { user, error } = await getAuthenticatedUser()
if (!user || error) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// ...
}
Rules:
- Always use
getUser()— it validates the JWT against Supabase Auth server. (BLOCKING) - Never use
getSession()alone for authorization — it trusts the local cookie without server validation. (BLOCKING) - Service role key must only be used in server-side code that needs admin access. Never expose to client.
Error Handling
// lib/errors.ts — structured error types
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly context?: Record<string, unknown>
) {
super(message)
this.name = 'AppError'
}
}
export class ValidationError extends AppError {
constructor(message: string, context?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', 400, context)
}
}
export class UnauthorizedError extends AppError {
constructor() {
super('Unauthorized', 'UNAUTHORIZED', 401)
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404)
}
}
// Route error handler wrapper
export function withErrorHandler(
handler: (req: Request) => Promise<Response>
): (req: Request) => Promise<Response> {
return async (req: Request) => {
try {
return await handler(req)
} catch (err) {
if (err instanceof AppError) {
// Log with full context server-side
console.error(`[${err.code}]`, err.message, err.context)
// Return safe response to client
return Response.json(
{ error: err.message, code: err.code },
{ status: err.statusCode }
)
}
// Unknown error — log full details, return generic message
console.error('[UNHANDLED]', err)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}
}
Rules:
- Log the full error (stack, context) server-side. (BLOCKING)
- Return only a generic message to the client for 500 errors — no stack traces, no error classes, no DB error messages. (BLOCKING)
- Include a
codefield in error responses for frontend error handling.
Upstash Rate Limiting
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
// Different limits for different endpoint types
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1 m'),
analytics: true,
prefix: 'rl:auth',
})
export const apiRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(60, '1 m'),
analytics: true,
prefix: 'rl:api',
})
export const uploadRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 h'),
analytics: true,
prefix: 'rl:upload',
})
// Helper — get real IP behind proxy
export function getClientIp(request: Request): string {
return (
request.headers.get('x-real-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
'unknown'
)
}
Usage:
export async function POST(request: Request) {
const ip = getClientIp(request)
const { success, limit, remaining, reset } = await authRatelimit.limit(ip)
if (!success) {
return Response.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': new Date(reset).toISOString(),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
}
)
}
// ...
}
Rule: Every auth endpoint must have rate limiting. Every file upload endpoint must have rate limiting. (BLOCKING)
Server Actions (Next.js)
// app/actions/orders.ts
'use server'
import { z } from 'zod'
import { getAuthenticatedUser } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
const createOrderSchema = z.object({
branchId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})),
})
export async function createOrderAction(formData: FormData) {
// Auth first — always
const { user } = await getAuthenticatedUser()
if (!user) throw new Error('Unauthorized')
// Validate input
const result = createOrderSchema.safeParse({
branchId: formData.get('branchId'),
items: JSON.parse(formData.get('items') as string),
})
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
try {
await createOrder(result.data, user.id)
revalidatePath('/orders')
return { success: true }
} catch (err) {
console.error('[createOrderAction]', err)
return { error: { _form: ['Failed to create order. Please try again.'] } }
}
}
Rule: Server actions must validate auth + input. Never trust the client. (BLOCKING)