Skip to main content

api-design

Next.js API design patterns for Route Handlers and Server Actions. Use when the user asks about "Route Handlers", "API routes in App Router", "Server Actions vs API routes", "input validation", "API response patterns", "streaming responses", "SSE", "webhooks in Next.js", "CORS", "API versioning", or needs guidance on building APIs with Next.js.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
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.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
api-design
description
Next.js API design patterns for Route Handlers and Server Actions. Use when the user asks about "Route Handlers", "API routes in App Router", "Server Actions vs API routes", "input validation", "API response patterns", "streaming responses", "SSE", "webhooks in Next.js", "CORS", "API versioning", or needs guidance on building APIs with Next.js.
# 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