Route handler patterns
Production-tested patterns for App Router route handlers — caching, streaming, auth guards, and error envelopes.
Auth guard wrapper
Wrap every handler in a reusable guard that validates the session before the route body executes. Return 401 with a consistent envelope.
export function withAuth(handler) {
return async (req) => {
const session = await validateSession(req)
if (!session) {
return Response.json(
{ error: 'unauthorized' },
{ status: 401 }
)
}
return handler(req, session)
}
}Streaming response
Use a ReadableStream when the response payload is large or generated incrementally. Set the correct headers so the client can consume chunks as they arrive.
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(
new TextEncoder().encode(`chunk ${i}\n`)
)
await new Promise(r => setTimeout(r, 500))
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain',
'X-Content-Type-Options': 'nosniff'
}
})
}Cache-control by verb
GET responses are cached at the edge for 60 seconds. Mutations skip the cache entirely and return a no-store directive.
export async function GET() {
const data = await fetchFromKV()
return Response.json(data, {
headers: {
'Cache-Control':
'public, s-maxage=60, stale-while-revalidate=30'
}
})
}
export async function POST(req: Request) {
const body = await req.json()
await writeToKV(body)
return Response.json({ ok: true }, {
headers: { 'Cache-Control': 'no-store' }
})
}Error envelope
Every route returns the same JSON shape so clients can branch on the error code without parsing status text.
function err(code: string, status: number) {
return Response.json({ error: code }, { status })
}
// Usage
if (!body.email) return err('missing_email', 400)
if (duplicate) return err('email_taken', 409)These patterns power every API route under /api/v1. Copy them freely — they are extracted directly from the Meridian production codebase.