Skip to main content

api-design

Next.js API design patterns for Route Handlers (app/api/**/route.ts): input validation, response and error shapes, streaming and SSE, webhooks, CORS, versioning, caching GET handlers, and choosing between a Route Handler and a Server Action. Not for form handling with Server Actions (form-handling) or for fetching data in Server Components (data-fetching).

Source facts

Repository
Agents-Store/codex-plugins
Last source activity
November 1, 2026 at 02:46
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
api-design
description
Next.js API design patterns for Route Handlers (app/api/**/route.ts): input validation, response and error shapes, streaming and SSE, webhooks, CORS, versioning, caching GET handlers, and choosing between a Route Handler and a Server Action. Not for form handling with Server Actions (form-handling) or for fetching data in Server Components (data-fetching).
# API Design ## Decision Matrix: Server Actions vs Route Handlers | Use Case | Recommended | Why | |----------|-------------|-----| | Form mutation from React component | **Server Action** | Built-in progressive enhancement, CSRF protection | | Data mutation from React component | **Server Action** | Direct integration with `useActionState`, `useOptimistic` | | External API consumers (mobile apps, 3rd parties) | **Route Handler** | Standard REST endpoints with HTTP methods | | Webhook receiver | **Route Handler** | Needs specific HTTP method + signature verification | | File download endpoint | **Route Handler** | Needs custom response headers and streaming | | OAuth callback | **Route Handler** | External service redirects to URL | | Cron job endpoint | **Route Handler** | Called by external scheduler | **Rule of thumb:** Server Actions for React UI mutations, Route Handlers for everything else. ## Route Handler Basics Route Handlers live in `app/**/route.ts` files and export HTTP method functions: ```typescript // app/api/posts/route.ts import { NextRequest } from 'next/server' export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const page = Number(searchParams.get('page') ?? '1') const limit = Number(searchParams.get('limit') ?? '10') const posts = await db.post.findMany({ skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' }, }) return Response.json(posts) } export async function POST(request: NextRequest) { const body = await request.json() const post = await db.post.create({ data: body }) return Response.json(post, { status: 201 }) } ``` ### Dynamic Route Handlers ```typescript // app/api/posts/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params const post = await db.post.findUnique({ where: { id } }) if (!post) { return Response.json({ error: 'Post not found' }, { status: 404 }) } return Response.json(post) } export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params await db.post.delete({ where: { id } }) return new Response(null, { status: 204 }) } ``` ### Route Context Helper (Next.js 16) Use `RouteContext` for strongly typed params: ```typescript // app/api/users/[id]/route.ts import type { NextRequest } from 'next/server' export async function GET(_req: NextRequest, ctx: RouteContext<'/users/[id]'>) { const { id } = await ctx.params return Response.json({ id }) } ``` > Types are generated during `next dev`, `next build` or `next typegen`. `RouteContext` is globally available — no import needed. ## Input Validation with Zod Always validate request bodies and query params: ```typescript // app/api/posts/route.ts import { z } from 'zod' import { NextRequest } from 'next/server' const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), published: z.boolean().default(false), }) export async function POST(request: NextRequest) { let body: unknown try { body = await request.json() } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }) } const parsed = CreatePostSchema.safeParse(body) if (!parsed.success) { return Response.json( { error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 422 } ) } const post = await db.post.create({ data: parsed.data }) return Response.json(post, { status: 201 }) } ``` ## Response Patterns ### Consistent Error Responses ```typescript // lib/api-utils.ts export function apiError(message: string, status: number, details?: unknown) { return Response.json({ error: message, details }, { status }) } export function apiSuccess<T>(data: T, status = 200) { return Response.json(data, { status }) } ``` ### Headers, Cookies, Redirects ```typescript import { cookies, headers } from 'next/headers' import { NextRequest } from 'next/server' export async function GET(request: NextRequest) { // Read cookies const cookieStore = await cookies() const token = cookieStore.get('token') // Read headers const headersList = await headers() const referer = headersList.get('referer') // Set response headers return Response.json({ data: 'value' }, { headers: { 'Cache-Control': 'public, max-age=3600', 'X-Custom-Header': 'value', }, }) } ``` ## Streaming Responses ### Server-Sent Events (SSE) ```typescript // app/api/events/route.ts export async function GET() { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { const send = (data: unknown) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } // Send events for (let i = 0; i < 10; i++) { send({ count: i, timestamp: Date.now() }) await new Promise(resolve => setTimeout(resolve, 1000)) } controller.close() }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } ``` Client consumption: ```tsx 'use client' import { useEffect, useState } from 'react' export function EventStream() { const [events, setEvents] = useState<any[]>([]) useEffect(() => { const source = new EventSource('/api/events') source.onmessage = (event) => { setEvents(prev => [...prev, JSON.parse(event.data)]) } return () => source.close() }, []) return <ul>{events.map((e, i) => <li key={i}>{JSON.stringify(e)}</li>)}</ul> } ``` ### Streaming JSON ```typescript // app/api/export/route.ts export async function GET() { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { const cursor = db.post.findManyCursor({ take: 100 }) for await (const batch of cursor) { controller.enqueue(encoder.encode(JSON.stringify(batch) + '\n')) } controller.close() }, }) return new Response(stream, { headers: { 'Content-Type': 'application/x-ndjson' }, }) } ``` ## Webhook Handler Pattern ```typescript // app/api/webhooks/stripe/route.ts import { NextRequest } from 'next/server' import Stripe from 'stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! export async function POST(request: NextRequest) { const body = await request.text() const signature = request.headers.get('stripe-signature')! // 1. Verify signature let event: Stripe.Event try { event = stripe.webhooks.constructEvent(body, signature, webhookSecret) } catch (err) { return Response.json({ error: 'Invalid signature' }, { status: 400 }) } // 2. Idempotency — check if already processed const existing = await db.webhookEvent.findUnique({ where: { eventId: event.id } }) if (existing) { return Response.json({ received: true }) // Already processed } // 3. Process event switch (event.type) { case 'checkout.session.completed': await handleCheckoutComplete(event.data.object) break case 'invoice.payment_failed': await handlePaymentFailed(event.data.object) break } // 4. Record event await db.webhookEvent.create({ data: { eventId: event.id, type: event.type } }) return Response.json({ received: true }) } ``` ## API Versioning Use route groups for versioning without code duplication: ``` app/api/ ├── v1/ │ └── posts/ │ └── route.ts # /api/v1/posts └── v2/ └── posts/ └── route.ts # /api/v2/posts ``` Or use a single handler with version detection: ```typescript // app/api/posts/route.ts export async function GET(request: NextRequest) { const version = request.headers.get('api-version') ?? 'v1' if (version === 'v2') { return Response.json(await getPostsV2()) } return Response.json(await getPostsV1()) } ``` ## File Download ```typescript // app/api/download/[filename]/route.ts import { readFile } from 'fs/promises' import path from 'path' export async function GET( request: NextRequest, { params }: { params: Promise<{ filename: string }> } ) { const { filename } = await params const filePath = path.join(process.cwd(), 'uploads', filename) try { const file = await readFile(filePath) return new Response(file, { headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, }, }) } catch { return Response.json({ error: 'File not found' }, { status: 404 }) } } ``` ## Caching Route Handlers `GET` Route Handlers are **not cached by default**; other HTTP methods are never cached. How you opt in depends on the caching model. Previous model (without `cacheComponents`) — route segment config and `fetch` options: ```typescript // app/api/config/route.ts — previous model: cacheComponents must be off // Static: cached at build time (GET only, no dynamic inputs) export const dynamic = 'force-static' export async function GET() { const data = await fetch('https://api.example.com/data') return Response.json(await data.json()) } ``` ```typescript // app/api/feed/route.ts — previous model: cacheComponents must be off // Time-based revalidation of a cached GET export const revalidate = 3600 // Revalidate every hour export async function GET() { const data = await fetch('https://api.example.com/feed') return Response.json(await data.json()) } ``` With `cacheComponents: true`, `dynamic` and `revalidate` are removed. A `GET` handler runs at request time by default and is prerendered only if it touches no uncached or runtime data. Cache data with `'use cache'` in a helper (it cannot go directly in the handler body) and set the lifetime with `cacheLife`: ```typescript // app/api/products/route.ts — Cache Components (cacheComponents: true) import { cacheLife } from 'next/cache' export async function GET() { const products = await getProducts() return Response.json(products) } async function getProducts() { 'use cache' cacheLife('hours') return db.product.findMany() } ``` Handlers that read `cookies()`, `headers()` or the `request` object are dynamic in both models: ```typescript // Dynamic (never cached) — uses cookies, headers, or request object export async function GET(request: NextRequest) { const cookieStore = await cookies() // This is dynamic because it reads cookies return Response.json({ data: 'dynamic' }) } ``` ## What This Skill Does NOT Cover - tRPC integration (use tRPC docs) - GraphQL endpoints (use Apollo/Yoga docs) - External API client patterns (fetch wrappers) - Database query optimization (ORM-specific) - WebSocket endpoints (use third-party like Pusher, Ably, or Socket.io)
View on GitHub