name: route-handler-design
description: "Use when designing or reviewing Next.js App Router Route Handlers: route.ts file placement, HTTP method exports, Web Request/Response APIs, body parsing, GET caching and opt-outs, dynamic segments, search params, CORS, Edge vs Node runtime choice, streaming responses, status and header discipline, error responses, and webhook endpoint shape. Use when the caller is mobile, third-party, webhook, server-to-server, cross-origin, or otherwise not your own typed UI. Do NOT use for internal UI mutations, broad API design, abstract HTTP semantics, request preprocessing, or full webhook reliability design. Do NOT use for design an internal create-comment form mutation triggered only from this app's UI (use server-actions-design). Do NOT use for read application data inside a Server Component during render (call the data source directly, not a self-fetch to your own Route Handler). Do NOT use for define the REST contract and resource model for a v2 public API (use api-design)."
license: MIT
allowed-tools: Read Grep
metadata:
relations: "{"related":["http-semantics","webhook-integration","streaming-architecture","client-server-boundary","server-actions-design","api-design","middleware-patterns"],"suppresses":["api-design"],"verify_with":["code-review","api-design"]}"
subject: backend-engineering
public: "true"
scope: "Use when designing or reviewing Next.js Route Handlers (the route.ts/route.js file convention in App Router): when a Route Handler is the right public HTTP endpoint surface vs Server Actions or Server Components, the HTTP-method-as-export contract (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), the Web-standard Request/Response interface (no Node req/res), the body-parsing primitives (request.json/formData/text/blob/arrayBuffer) and one-shot body consumption, the default caching behavior of GET responses (NOT cached by default since Next 15; opt IN via dynamic = 'force-static') and the Cache Components use cache model, async params/cookies/headers and RouteContext typed params, dynamic segments and search params, the rule that every Route Handler is a PUBLIC surface that must authenticate/authorize/validate inside the handler, manual CORS, runtime/deployment selection (Node default and recommended after Vercel folded standalone Edge Functions onto Vercel Functions; the Edge runtime still selectable; maxDuration/preferredRegion/static-export limits), streaming via ReadableStream including pull() backpressure, status-code and header discipline, error responses, webhook-style handlers (signature verify before parse, ACK fast, after() for short post-response work), and the design rule that a Route Handler is the right surface when the caller is not your own Next.js UI. Do NOT use for internal mutations triggered from your own UI (use server-actions-design), for render-time reads from your own Server Components (call the data source directly), for the broader REST/contract/versioning discipline (use api-design), for HTTP method/status semantics in the abstract (use http-semantics), for request preprocessing across all routes — now proxy.ts/middleware.ts (use middleware-patterns), or for the broader webhook reliability story — HMAC verification, idempotency, retries, queue handoff (use webhook-integration)."
taxonomy_domain: engineering/backend
stability: experimental
keywords: "["Next.js Route Handler","route.ts file","GET Route Handler caching","Cache Components use cache","RouteContext typed params","NextRequest geo ip removed","request.json formData parsing","after Route Handler webhook","ReadableStream pull backpressure","Edge runtime vs Node runtime"]"
triggers: "["how do I expose an API endpoint in Next.js App Router","when should I use a Route Handler instead of a Server Action","why is my Route Handler GET not cached anymore","how do I statically cache a GET Route Handler in Next 15 or 16","how do I type dynamic params in route.ts","how do I receive a webhook in Next.js without breaking raw body verification","how do I ACK a webhook fast and run short work after the response","how do I return a streaming response from an API route","how do I stream a large response with ReadableStream backpressure","how do I parse a JSON body in route.ts","how do I set CORS headers on a Next.js API route","Edge runtime vs Node runtime for an API route"]"
examples: "["design the route.ts that receives a Stripe webhook — verify signature before parsing the body, return 200 immediately, queue the heavy work","decide whether a 'export user data' endpoint should be a Route Handler or a Server Action","add CORS to a Route Handler that mobile clients call from a different origin","cache a GET Route Handler that returns rarely-changing data (force-static or use cache)","return a streaming binary response (PDF generation, large CSV export) from a Route Handler"]"
anti_examples: "["design an internal create-comment form mutation triggered only from this app's UI (use server-actions-design)","read application data inside a Server Component during render (call the data source directly, not a self-fetch to your own Route Handler)","define the REST contract and resource model for a v2 public API (use api-design)","explain what an HTTP 422 means vs 400 (use http-semantics)","add an auth check that runs before every protected route in proxy.ts/middleware.ts (use middleware-patterns)","design the idempotency-key + retry + dead-letter-queue strategy for a webhook (use webhook-integration)","design the resource model, versioning, pagination, or error envelope of an HTTP API (use api-design)","decide what an HTTP method, status code, or header should mean per RFC 9110 (use http-semantics)","design signature verification, idempotency keys, retry semantics, or dead-letter queues for vendor webhooks (use webhook-integration)","design a cross-cutting streaming model with Web Streams, SSE, or backpressure (use streaming-architecture)"]"
mental_model: "|"
purpose: "|"
concept_boundary: "|"
analogy: "A Route Handler is to a Next.js app what a service window at a government office is to its workflow — different windows handle different services (GET /posts, POST /comments); each window has a posted sign saying which forms it accepts and what stamps it returns; you do not walk into the back office (Server Action) unless you work there. The window is the contract: filesystem path = window number, export name = service offered, function body = the clerk's actual work."
misconception: "|"
skill_graph_source_repo: "https://github.com/jacob-balslev/skill-graph"
skill_graph_project: Skill Graph
skill_graph_canonical_skill: skills/backend-engineering/route-handler-design/SKILL.md
skill_graph_export_description: shortened for Agent Skills 1024-character description limit; canonical source keeps the full routing contract
skill_graph_canonical_description_length: "1972"
skill_graph_export_description_projection: anti_examples
skill_graph_export_description_projection_truncated: "true"
Route Handler Design
Concept of the skill
A Next.js Route Handler is a file named route.ts (or route.js) under the app/ directory that exports one async function per HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). Each export receives a standard Web Request and returns a standard Web Response (or NextResponse which extends it). The file shape IS the contract — the filesystem path defines the URL; the export name defines the method; the function body defines the handler. No Node-style req/res; no middleware chain; no per-method routing config. Unhandled methods auto-return 405. A route.ts and a page.tsx cannot share a path; one URL has one purpose. Every Route Handler is a PUBLIC HTTP surface — "only our frontend calls this" is not a security boundary; authenticate, authorize, and validate inside the handler. The five body-reading methods (request.json(), request.formData(), request.text(), request.blob(), request.arrayBuffer()) are mutually exclusive — a body can be read only once; if you need both raw and parsed (for webhook HMAC verification), read text() and parse yourself.
Replaces the Pages Router's Node-style /api/* endpoints with Web-standard handlers that compose into the App Router's file-system convention. Solves the problem that Pages Router endpoints were tied to Node's req/res interface (not portable to Edge, Cloudflare Workers, or Deno) and required separate routing-layer registration. The deeper purpose emerges from the App Router introducing Server Actions as a competing mutation surface: Route Handlers become purely the public HTTP endpoint surface, used when the caller is not your own typed UI — mobile apps, third-party integrations, webhooks, server-to-server calls, cross-origin client-side fetches, binary downloads, server-sent events, anything that benefits from explicit HTTP semantics. The single most common Route Handler mistake in App Router code is using one for an internal mutation that a Server Action would serve better — duplicate type contracts, manual fetch wiring, no progressive enhancement, no built-in revalidation.
Distinct from server-actions-design, which owns the internal mutation surface — a function the bundler turns into a network call from your own UI; this skill owns the public-endpoint surface for callers who aren't the Next.js bundler's typed call sites. Use Server Actions when the only caller is this app's UI; use Route Handlers when the caller is anything else or when you need fine-grained HTTP control. Distinct from a Server Component reading data on render — that should call the data source (DB/service) directly, never self-fetch its own Route Handler. Distinct from middleware-patterns, which runs once before route resolution and applies to many routes via a matcher (the file is middleware.ts, renamed to proxy.ts in Next 16 and Node-only there) — Route Handlers run for one route and one method after that pass. Distinct from api-design, which owns the broader REST/contract/versioning discipline — this skill owns the Next.js implementation surface that hosts the contract. Distinct from http-semantics, which owns the abstract method/status/header semantics — this skill owns honoring them in App Router. Distinct from webhook-integration, which owns the full webhook reliability story (HMAC details, idempotency keys, retry semantics, dead-letter queues) — this skill covers the endpoint surface only. Distinct from streaming-architecture, which owns the cross-cutting streaming model — this skill covers Route Handler streaming specifically. Distinct from client-server-boundary (which owns the bundler's component split, a different boundary). A Route Handler is to a Next.js app what a service window at a government office is to its workflow — different windows handle different services (GET /posts, POST /comments); each window has a posted sign saying which forms it accepts and what stamps it returns; you do not walk into the back office (Server Action) unless you work there. The window is the contract: filesystem path = window number, export name = service offered, function body = the clerk's actual work. The wrong mental model is that Route Handlers are "Next.js's API routes" and that every HTTP endpoint in a Next.js app belongs there. They are, but with a critical refinement after App Router: internal mutations from your own UI belong in Server Actions, not Route Handlers. Using a Route Handler for an internal mutation duplicates the type contract (request shape + response shape + manual fetch wiring + manual revalidation), loses progressive enhancement (form submission without JS), and loses built-in revalidation. Adjacent misconceptions: that request.body can be read multiple times (it cannot — pick one of json/formData/text/blob/arrayBuffer; if you need both raw and parsed, read text() and parse yourself); that GET responses are statically cached by default (they were in Next 14; since Next 15 Route Handlers are NOT cached by default — you opt IN with export const dynamic = 'force-static', so the stale-data risk has flipped to an accidental-uncached-cost risk, not a leaked-cached-response risk — though force-static on a per-user GET still leaks one user's data to all); that params/cookies()/headers() can be read synchronously (they cannot since Next 16 — await them); that request.geo/request.ip exist on NextRequest (removed in Next 15 — they were middleware-only even before that; use geolocation()/ipAddress() from @vercel/functions); that a Server Component should fetch its own Route Handler to read data (it should not — that adds an HTTP round-trip and can fail during prerender/build; call the data function directly); that "only our frontend calls this endpoint" is a security boundary (it is not — any client can send the same request; authenticate/authorize/validate inside the handler); that calling request.json() on a webhook is fine (it is not — the parse can mutate whitespace and break HMAC verification; read raw bytes via text(), verify, then parse); that webhooks should do heavy work inline (they should not — vendors retry slow ACKs, producing duplicate processing; ACK fast with 200 and durably enqueue the work); that Access-Control-Allow-Origin: '*' works with credentialed requests (it does not — browsers refuse credentials with wildcard origin; allowlist explicit origins); that Edge runtime is always faster (it is not — Vercel folded standalone Edge Functions onto Vercel Functions and recommends Node, though runtime = 'edge' stays selectable; Fluid Compute + bytecode caching closed most of the cold-start gap, while Edge keeps a tight capability surface and many vendor SDKs require Node's crypto).
Coverage
The discipline of designing Next.js App Router route.ts / route.js handlers: the file-and-export convention (one async function per HTTP method, one URL per filesystem path), the Web-standard Request/Response interface that replaces the Node req/res pair, the body-parsing primitives (request.json / formData / text / blob / arrayBuffer) and one-shot body consumption, the off-by-default GET caching behavior (Next 15+) and the opt-in mechanisms (dynamic = 'force-static', the Cache Components use cache model), async request APIs (params, cookies(), headers() — await required since Next 16) and RouteContext typed params, dynamic segments and search-param access, the rule that every Route Handler is a public surface that must authenticate/authorize/validate inside the handler, manual CORS, the Edge-vs-Node runtime choice (Node now default and recommended) and deployment knobs (maxDuration, preferredRegion, static-export limits), streaming responses via ReadableStream with pull() backpressure, status-code and header discipline, error response shaping, the canonical webhook pattern (verify signature against the raw body before parsing, ACK fast, after() for short post-response work), a version-drift map across Next 13→16, and the central design rule that determines when a Route Handler is the right surface at all: the caller is not your own typed UI or render tree.
Philosophy of the skill
The App Router collapsed three things that used to be distinct in the Pages Router:
- Pages: rendered routes — moved to Server Components and
page.tsx. - API routes: HTTP endpoints under
/api/*— moved to Route Handlers inroute.ts. - Custom server handlers: middleware, edge functions — partly absorbed by
middleware.ts(renamedproxy.tsin Next 16), partly by per-route runtime selection.
The Pages Router /api/foo.ts exported a default function taking Node's req and res. The Route Handler exports per-method async functions taking a Web Request and returning a Web Response. The change is not cosmetic — it makes Next.js endpoints portable to any runtime that speaks Web standards (Edge, Cloudflare Workers, Deno, browser Service Workers in principle) and removes a category of "Node-specific" footguns.
The deeper shift is that the App Router introduced a competing mutation surface in Server Actions. Before, every mutation needed an API route. Now, most mutations triggered from the app's own UI should use a Server Action (one declaration, no manual wire format). Route Handlers remain the right surface only when the caller is not the Next.js bundler's typed call site — mobile apps, third-party integrations, webhooks, server-to-server calls, server-sent events, binary downloads, and anything else that benefits from explicit HTTP semantics.
The Route Handler is the public HTTP endpoint surface. Use it when you need an HTTP endpoint. Use a Server Action when you need a mutation triggered from this app's UI. The two surfaces can coexist — many apps publish a Route Handler /api/v1/posts for external clients AND use Server Actions for the same mutations from their own forms. Next's own docs frame Route Handlers as the App Router's "Backend for Frontend" layer: an HTTP endpoint beside the UI, not a full backend replacement for every internal read or write. Do not fetch your own Route Handler from a Server Component just to read application data — call the database, service, or cached data helper directly. A self-fetch adds an avoidable HTTP round-trip and can fail during static generation or prerendering, because no request server is running for that internal URL at build time.
Treat every Route Handler as public. "Only our frontend calls this" is not a security boundary: a browser, mobile app, script, or server-to-server client can send the same HTTP request. Authentication, authorization, runtime input validation, rate limiting, tenant/user derivation from server-trusted state, and safe error shaping belong inside the handler or in code it calls. A proxy.ts/middleware auth redirect can be a useful UX gate, but the route itself must still reject an unauthorized direct request — middleware can be skipped, mis-matched, or bypassed.
When to Use What
| Caller | Right surface | Why |
|---|---|---|
This app's <form> or button |
Server Action | One declaration; no wire format; progressive enhancement; revalidation built in |
| This app's component reading data on render | Server Component / data helper | No round-trip; co-located with the render that uses the data. Do NOT self-fetch your own Route Handler — call the data source directly |
| Mobile app, third-party integration, server-to-server | Route Handler | Explicit HTTP contract; the caller does not run the Next.js bundler |
| Webhook from Stripe / Shopify / GitHub | Route Handler | Need raw-body access for signature verification; need exact status codes; vendor expects standard HTTP |
| Streaming SSE, binary downloads, large CSV/PDF | Route Handler | Returns a ReadableStream with the right headers; Server Actions can't model this |
Client-side fetch() from a Client Component |
Route Handler if it's a real API, Server Action if it's a mutation | Don't fetch your own internal mutation; that's what Server Actions exist to replace |
| Static JSON / feed endpoint (Next 15+) | Route Handler with explicit static/cache opt-in | GET is dynamic by default in current Next; cache only when the response is safe to share across callers |
| Public API with many nested routes + shared validation | Route Handler entrypoint, optionally with a small router (e.g. Hono) inside | Next still owns the route.ts entrypoint and the surface-choice discipline; a router library only reduces boilerplate after the Route Handler choice is made |
Project deployed with output: 'export' |
Only a static GET Route Handler |
Non-GET handlers and dynamic request-time behavior need a server/runtime |
The single most common Route Handler mistake in App Router code is using a Route Handler for an internal mutation that a Server Action would serve better — duplicate type contracts, manual fetch wiring, no progressive enhancement, no built-in revalidation.
The File-and-Export Contract
// app/api/posts/route.ts
export async function GET(request: Request) {
const posts = await db.post.findMany()
return Response.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
const created = await db.post.create({ data: body })
return Response.json(created, { status: 201 })
}
The filesystem path app/api/posts/route.ts registers /api/posts. The exports GET and POST register the methods. Unhandled methods auto-return 405 Method Not Allowed. There is no router-level config object, no separate registration step.
A Route Handler file cannot coexist with a page.tsx at the same path — /api/posts/page.tsx and /api/posts/route.ts collide and Next.js rejects the build. Choose one or the other per URL.
The supported method exports are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. When you implement GET, Next can answer HEAD requests for clients that only need headers; implement an explicit HEAD export only when the header-only path needs different work or stricter control. Route Handlers can be placed at any route segment, not only under /api — keep /api/* for externally recognizable API surfaces, but route-adjacent handlers such as app/rss.xml/route.ts or app/sitemap.xml/route.ts are appropriate when the URL is itself the product surface.
Dynamic segments
// app/api/posts/[id]/route.ts
import type { NextRequest } from 'next/server'
// Idiomatic Next 15.5+: the generated RouteContext helper types params from the path.
export async function GET(request: NextRequest, ctx: RouteContext<'/api/posts/[id]'>) {
const { id } = await ctx.params // params is a Promise — await it
const post = await db.post.findUnique({ where: { id } })
return post ? Response.json(post) : new Response(null, { status: 404 })
}
params is a Promise and must be awaited — synchronous access was removed in Next 16 (it had a temporary sync-compat shim in Next 15). The RouteContext<'/path/[id]'> global helper (generated by next dev / next build / next typegen) is the current idiomatic typing; the older hand-written { params: Promise<{ id: string }> } shape still works and is the right fallback if a project has not generated route types yet. Run npx next typegen to regenerate the helpers after changing dynamic segments.
Search params
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const limit = Number(searchParams.get('limit') ?? '20')
// ...
}
URLSearchParams is the Web-standard read surface. There is no Next-specific helper for query strings beyond constructing a URL from request.url. (Note: reading request.url is request-time data — under Cache Components it opts the handler out of prerendering, as cookies()/headers() do.)
Body Parsing
The Request object exposes five body-reading methods:
| Method | Returns | Use when |
|---|---|---|
request.json() |
parsed JSON | Content-Type: application/json |
request.formData() |
FormData | Content-Type: multipart/form-data or application/x-www-form-urlencoded |
request.text() |
string | Plain text, raw payload, or when you need to verify a signature against the raw bytes |
request.blob() |
Blob | File upload, binary payload you'll re-serve |
request.arrayBuffer() |
ArrayBuffer | Low-level binary processing |
A body can only be read once. If you need both the raw body (for HMAC verification) and the parsed body (for handler logic), call request.text() and parse it yourself. The webhook section below demonstrates this.
request.bodyUsed (a boolean) tells you whether the stream has already been consumed — useful for diagnosing a double-read (bodyUsed === true before your read means an earlier line already drained it). request.clone() can give you a second readable copy, but clone before reading either branch, and use it sparingly: the clone forces the runtime to buffer the body so both branches can be read, which creates unbounded memory pressure for large or streamed payloads. For the common raw-and-parsed case, prefer text()-then-JSON.parse over clone() — it buffers once, explicitly, and you control the bound.
Caching: Off by Default; How to Opt In
This is the most-changed area of the Route Handler surface — verify which Next major you target. In Next 14, a
GEThandler that didn't read request-scoped sources was statically cached by default and you opted out withdynamic = 'force-dynamic'. Since Next 15 the default flipped: Route Handlers are NOT cached by default. Every method, includingGET, runs at request time unless you explicitly opt in. Code and advice written for Next 14 (including "remember to addforce-dynamic") is now backwards.
If a handler returns user-, tenant-, cookie-, header-, time-, or auth-dependent data, leave it dynamic (the default) and make that intentional with the code you read (cookies, headers, auth/session helpers, request.url) and the tests you write.
To cache a GET in the classic (non-Cache-Components) model, opt in with segment config:
// app/api/posts/route.ts
export const dynamic = 'force-static' // cache: prerender this GET, serve the same response
// or
export const revalidate = 60 // cache, then revalidate at most every 60s
POST, PUT, PATCH, DELETE, HEAD, and OPTIONS are never cached, even when placed alongside a cached GET in the same file. Only GET honors the opt-in.
Footgun: force-static on a GET whose response depends on the request user (auth, cookies, per-user data) serves one user's response to everyone. A cached API response is shared state; never cache a handler that depends on identity unless the cache key is deliberately partitioned by that identity. If the handler is per-user, leave it uncached (the default). Conversely, the old reflex of sprinkling force-dynamic everywhere is now redundant in most cases, because uncached is already the default.
With Cache Components (Next 16 cacheComponents: true)
When Cache Components is enabled (cacheComponents: true in next.config, the stable successor to experimental.dynamicIO/experimental.useCache and the route-level experimental_ppr flag), GET Route Handlers follow the same prerender model as UI routes:
- A handler that touches no uncached or runtime data is prerendered at build time.
- Reading runtime APIs (
cookies(),headers(),connection(),request.url,request.headers, a DB query, the filesystem, or non-deterministic ops likeMath.random()) terminates prerendering and the handler runs at request time. - To include uncached data (e.g. a DB read) in a prerendered response, wrap it in a
use cachehelper with acacheLifeprofile. Read request-time APIs (cookies, headers) outside the cached scope and pass only serializable values into the cached function when they are meant to be part of the cache key:
// app/api/products/route.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function GET() {
const products = await getProducts()
return Response.json(products)
}
async function getProducts() {
'use cache' // CANNOT go directly in the handler body — must be a helper
cacheLife('hours')
cacheTag('products')
return await db.query('SELECT * FROM products')
}
use cache cannot be placed directly inside the Route Handler body — extract it to a helper. Cached responses revalidate per cacheLife on the next request. In Next 16, cacheLife and cacheTag are stable (drop the old unstable_ prefix). The old segment options dynamic, revalidate, and fetchCache are removed/disabled under Cache Components — the segment config knobs that remain in that model are runtime, preferredRegion, maxDuration, and dynamicParams. Check the project's Next config before suggesting a segment-config fix.
revalidateTag now requires a second cacheLife argument — the single-arg form is a TS error. In a Route Handler, call revalidateTag(tag, 'max') for stale-while-revalidate, or revalidateTag(tag, { expire: 0 }) when an external webhook needs immediate expiration. updateTag(tag) is the Server-Actions-only "read-your-writes" variant for immediate refresh in the same request.
NextRequest and NextResponse
Standard Web Request and Response work fine. Next provides extended versions for convenience:
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const session = request.cookies.get('session')?.value
const cursor = request.nextUrl.searchParams.get('cursor')
return NextResponse.json({ session: Boolean(session), cursor }, { status: 200 })
}
NextRequest extends Request with .cookies, .nextUrl. NextResponse extends Response with .cookies.set/.delete, NextResponse.redirect, NextResponse.rewrite, NextResponse.json (a convenience that mirrors Response.json).
Drift fix (Next 15):
request.geoandrequest.ipwere removed fromNextRequestin Next 15 — and even before that they were only populated in middleware, never in Route Handlers. For geolocation/IP on Vercel, import from@vercel/functions:import { geolocation, ipAddress } from '@vercel/functions' export function GET(request: Request) { const { country, city } = geolocation(request) const ip = ipAddress(request) // ... }Off Vercel, read the platform's forwarded headers (
x-forwarded-for, provider-specific geo headers) directly fromrequest.headers. Treat geo/IP as non-security-critical hints — they are spoofable header data, not an authentication signal.
Use NextRequest/NextResponse when you need cookies. Stick with standard Request/Response when you don't — it makes the handler more portable.
Streaming Responses
A Route Handler can return a ReadableStream directly. Useful for SSE, AI streaming, large file generation, and chunked CSV/JSON exports.
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of generateData()) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
},
})
}
Backpressure: the start(controller) form above eagerly drains the whole source as fast as the loop runs, enqueueing every chunk regardless of whether the consumer is keeping up — fine for small, bounded, or already-buffered sources, but it buffers an entire slow-consumer backlog in memory for large or iterator-backed streams. For those, drive the stream from the pull(controller) method instead: the runtime calls pull only when the internal queue wants more (respecting the consumer's read rate and the highWaterMark/CountQueuingStrategy), so a slow client throttles production instead of forcing you to buffer it all. Reach for pull whenever the source is large, unbounded, or backed by an async iterator you don't want to run ahead of the reader.
function iteratorToStream(iterator: AsyncIterator<Uint8Array>) {
return new ReadableStream<Uint8Array>({
async pull(controller) { // called on demand, not eagerly
const { value, done } = await iterator.next()
if (done) return controller.close()
controller.enqueue(value)
},
})
}
The Node runtime streams fine — on Vercel, Fluid Compute supports long-lived streaming responses without the per-connection memory pressure that once made Edge the safer choice. Historically the Edge runtime was recommended for long-lived streams (lower per-connection overhead); with standalone Edge Functions now folded onto Vercel Functions and Node recommended (see below), plus Fluid Compute available, default to Node and verify your host's streaming/buffering behavior (some platforms buffer Node responses unless you stream explicitly or set the right headers like Content-Type, Cache-Control, and Connection). Test the deployed platform for buffering, timeouts, and abort behavior — these vary by host.
For AI-specific token streaming, prefer the official AI SDK response helpers inside the Route Handler rather than hand-rolling the token/SSE protocol. For general streaming design choices (SSE vs WebSocket, backpressure semantics, partial-result correctness), route to streaming-architecture.
The Webhook Pattern
Webhooks need three things almost always: raw body access, signature verification before parse, fast acknowledgment.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
export const runtime = 'nodejs' // some HMAC libs need Node crypto
export const dynamic = 'force-dynamic' // belt-and-suspenders: never cache a webhook (POST isn't cached anyway)
export async function POST(request: Request) {
const signature = (await headers()).get('stripe-signature') // headers() is async (Next 16)
if (!signature) return new Response('Missing signature', { status: 400 })
const rawBody = await request.text() // raw bytes for HMAC
let event
try {
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`Webhook signature verification failed: ${err.message}`, { status: 400 })
}
// ACK fast — but DURABLY persist the event before returning 200.
// `await` a real enqueue (a DB row insert, a queue `send`) — NOT a fire-and-forget call.
// If the process dies after the 200 but before the work is persisted, the vendor's
// retry is the only thing that saves you, and only if you returned non-2xx. So: persist,
// then ACK.
await enqueueEventForProcessing(event) // durable: DB insert / queue send, awaited
return new Response(null, { status: 200 })
}
Key decisions encoded above:
request.text(), notrequest.json()— you need the raw bytes for HMAC; parsing first would mutate whitespace and break verification.await headers()— request-time APIs are async; synchronous access was removed in Next 16.- Verify before any business logic — reject unauthenticated calls with a fast 400 before touching the database.
- ACK fast, but persist first — return 200 within seconds, after the event is durably handed off (an awaited DB insert or queue
send). A bare unawaitedqueueEventForProcessing(event)is a footgun: the serverless instance can be frozen or killed the instant you return, dropping in-flight fire-and-forget work with no retry. The durable enqueue is what makes the fast ACK safe; the heavy processing then runs off that queue. Useafter(next section) only for droppable post-response work, never for the critical enqueue. Vendors interpret slow ACKs as failures and retry, which produces duplicate delivery — so the downstream consumer must also be idempotent (that reliability layer iswebhook-integration). runtime = 'nodejs'— some vendor SDKs need Node-only crypto APIs (Stripe'sconstructEventdoes in some versions). Edge supports Web Crypto, but with Node now the default-and-recommended runtime there's rarely a reason to switch a webhook to Edge.
Keep webhooks out of the proxy/middleware matcher
Exclude webhook paths from the cross-cutting proxy.ts / middleware.ts matcher unless there is a measured reason to include them. Current Next.js proxy can clone and buffer the request body up to proxyClientMaxBodySize (default 10MB); when a payload exceeds the ceiling the request continues with only a partial body available, and any upstream redirect/header/body manipulation makes raw-signature reasoning harder. The Route Handler should be the first application code that reads the body for exact HMAC verification — anything that buffers, clones, or transforms the body before it reaches the handler can break signature verification on large or exact-signature payloads.
Post-response work: after vs a durable queue
Next ships after (import { after } from 'next/server'; stable since Next 15.1, introduced as unstable_after in 15.0) to schedule a callback that runs after the response is sent without blocking it — usable in Route Handlers, Server Functions, Server Components, and proxy:
import { after } from 'next/server'
export async function POST(request: Request) {
const event = await verifyAndParse(request)
after(() => logWebhookReceipt(event.id)) // runs post-response; does NOT block the 200
return new Response(null, { status: 200 })
}
after is the right tool for non-critical, fire-and-forget post-response side effects — analytics, audit logging, cache warming. It replaces the old dangling-promise pattern (an untracked promise started after return can be cancelled or time-limited by the platform, and its failures disappear from the request path). But it is not a durable work queue: it extends the serverless invocation's lifetime via waitUntil and runs within the route's configured maxDuration, so if the function instance is killed or crashes before the callback finishes, that work is lost with no retry. For webhook processing that must happen exactly once — the order is fulfilled, the subscription is provisioned — still hand off to a real queue (the ACK-fast-then-queue pattern above), or persist a receipt before using after() to dispatch follow-up work by ID. The two compose: ACK with 200, durably enqueue the critical work, and use after for the cheap telemetry around it.
The reliability concerns beyond the endpoint itself — idempotency keys, retry semantics, dead-letter queues, replay protection — belong to webhook-integration. This skill covers the framework-specific endpoint surface only.
CORS
There is no built-in CORS helper in Route Handlers. Set headers manually, and handle the preflight OPTIONS request explicitly:
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: CORS_HEADERS })
}
export async function POST(request: Request) {
const data = await request.json()
return Response.json({ ok: true }, { headers: CORS_HEADERS })
}
Never reflexively allow * for credentialed requests — it doesn't work with Access-Control-Allow-Credentials: true and is a footgun for cookies and auth. Allowlist explicit origins. If credentials are required, add Access-Control-Allow-Credentials: true only alongside an explicit allowed origin, never with *. Expose any custom response headers browser clients need to read with Access-Control-Expose-Headers.
When the allowlist has more than one entry, you reflect the request's Origin back in Access-Control-Allow-Origin per request (the header can carry only one origin, not a list). The moment the header varies by request origin, you must also send Vary: Origin:
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])
function corsHeaders(origin: string | null) {
const allow = origin && ALLOWED.has(origin) ? origin : ''
return {
'Access-Control-Allow-Origin': allow,
'Vary': 'Origin', // so a shared cache keys per-origin
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
}
Without Vary: Origin, a shared/CDN cache can store the CORS response computed for one origin and replay it to a request from a different origin — either leaking access to an origin you meant to allow only conditionally, or wrongly denying a permitted one. Vary: Origin tells the cache to key on the request Origin.
If many routes need the same CORS policy, push it to middleware-patterns (or next.config headers). Note the Next 16 rename: middleware.ts → proxy.ts, which runs on the Node runtime only and is meant primarily for request modification, rewrites, and redirects. It can set/forward response headers (so global CORS header injection remains viable) and it can return a Response/NextResponse directly — e.g. short-circuiting an unauthenticated request with Response.json({ error }, { status: 401 }), or answering a CORS preflight with NextResponse.json({}, { headers }) (this has been supported since the advanced-middleware release in Next 13.1). What still belongs in a Route Handler is the route's own response — the resource body, streamed output, the per-method GET/POST/etc. handler — not an early-exit guard. Next positions proxy as a last resort; reach for it for cross-cutting interception, not as the place to author an endpoint's real payload.
Status Codes and Errors
Honor HTTP semantics (see http-semantics for the full discipline):
- 200 — success with body
- 201 — created (returning the new resource)
- 202 — accepted: the work was handed off and will complete asynchronously (return an operation/status resource)
- 204 — success, no body
- 400 — client error: malformed input
- 401 — unauthenticated
- 403 — authenticated but not authorized
- 404 — resource not found
- 409 — conflict (e.g., duplicate)
- 422 — validation failure (unprocessable entity)
- 429 — rate limited
- 500 — unexpected server error
- 503 — temporarily unavailable
export async function POST(request: Request) {
try {
const parsed = Schema.safeParse(await request.json())
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const created = await db.post.create({ data: parsed.data })
return Response.json(created, { status: 201 })
} catch (err) {
console.error(err)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}
A thrown exception that escapes the handler becomes a 500. That's a usable fallback, but explicit try/catch plus a structured error envelope is better for any handler clients consume programmatically.
Runtime and Deployment
export const runtime = 'nodejs' // the default; 'edge' is still a documented, selectable runtime
export const maxDuration = 30 // seconds the function may run (also bounds `after()` work)
export const preferredRegion = 'iad1'
| Capability | Edge | Node |
|---|---|---|
Web Crypto (crypto.subtle) |
✅ | ✅ |
Node crypto module |
❌ | ✅ |
Node fs, child_process, net |
❌ | ✅ |
| Most npm packages | ⚠️ depends on package | ✅ |
| Cold start | ~10–50ms (historical edge) | greatly reduced by Fluid Compute: bytecode caching + Scale-to-One warm instances |
| Memory ceiling | lower | higher |
| Long-lived streams | ✅ historically | ✅ on Fluid Compute |
| Vendor SDKs that need Node crypto | ❌ check vendor support | ✅ |
Pick the runtime by dependency surface, data locality, resource/duration profile, and measured behavior — not by a "faster" reflex:
- Use Node when the handler needs Node
crypto/fs, database drivers, native modules, or vendor SDKs that assume Node; when work is close to a regional database; or when it needs higher memory / longer duration. - Use Edge when the handler uses only Web APIs (
fetch, Web Crypto, Web Streams), all imports are Edge-compatible, the work is lightweight and short, and geographic low latency near callers (without cross-region data penalties) genuinely wins.
Platform shift — read the scope precisely (verify the exact wording/date against the live Vercel changelog). Vercel folded the standalone Edge Functions product onto Vercel Functions (the "Edge Middleware and Edge Functions are now powered by Vercel Functions" changelog) and now recommends the Node.js runtime for new work, citing full API support, Fluid Compute, and Active CPU pricing. This is not the same as the Next.js
export const runtime = 'edge'option disappearing: the Edge runtime is still a documented, selectable runtime, andruntime = 'edge'still works at the framework level. The accurate summary is Node is now the default and recommended target; Edge remains selectable — not "Edge is gone." (The two models that proposed this enrichment cited conflicting effective dates; do not assert a specific cutoff date as fact — confirm it against the live changelog before quoting.) Fluid Compute (enabled by default for new Vercel projects since 2025) plus Node bytecode caching and Scale-to-One warm instances closed most of the cold-start gap that was Edge's headline advantage, which is why the recommendation flipped. Off Vercel, the Edge runtime maps to whatever Web-standard runtime your host provides — evaluate that host's own guidance.
Default to Node — it is now both the framework default and the recommended target. Reach for runtime = 'edge' only with a concrete, host-supported reason. Always re-test after switching — package compatibility breaks often, and the historical "Edge is faster" intuition no longer holds by default.
Deployment constraints
Where a Route Handler can run depends on how the app is deployed — design with the target in mind:
- Static export (
output: 'export') ships only static assets, so only a fully staticGETworks — it must declareexport const dynamic = 'force-static'and read no request-scoped data. Any handler that needs the request (other methods, dynamic GETs, webhooks) is unsupported under export and needs a server runtime instead. (proxy.ts/middlewareis likewise unsupported in static export.) - Lambda-style / serverless deploys run each invocation in an isolated, possibly cold instance. A handler cannot rely on shared in-memory state (a module-level counter or cache is per-instance and evaporates) or on a persistent local filesystem across requests. Use external state — a database, Redis, object storage, or the platform's runtime cache — for anything that must survive between requests or be shared across instances.
- Long-lived connections don't fit this surface. A Route Handler models one request → one response (it can stream a
ReadableStream, but the request still completes). WebSockets and other persistent bidirectional connections are not a Route Handler — Vercel Functions do not support native WebSocket servers; run them on a dedicated WebSocket server or a realtime platform service. - Long work should not hold a Route Handler open. Return
202 Acceptedplus an operation/status resource (seeapi-design) or enqueue a job/webhook pipeline, rather than blocking the response pastmaxDuration.after()covers short post-response work only, bounded bymaxDuration; it is not a durable queue.
Version Drift Map (Next 13 → 16)
This surface moves fast. When reading or reviewing existing code, identify the target Next major first:
| Concern | Next 13/14 | Next 15 | Next 16 |
|---|---|---|---|
| GET caching default | cached by default; opt out with force-dynamic |
NOT cached by default; opt in with force-static |
same as 15; plus Cache Components (use cache) model |
params typing |
plain object (sync) | Promise (sync-compat shim) |
Promise, sync access removed; use RouteContext<…> |
cookies() / headers() |
sync | async (sync-compat shim) | async, sync access removed |
request.geo / request.ip |
middleware-only | removed → @vercel/functions |
removed |
| Cross-route preprocessing file | middleware.ts (Edge) |
middleware.ts (Edge) |
renamed proxy.ts, Node-only; can return a Response/NextResponse (e.g. auth short-circuit), but authoring an endpoint's real payload still belongs in a Route Handler |
| PPR / dynamic IO | experimental flags | experimental.ppr / dynamicIO |
cacheComponents: true (stable); old flags + dynamic/revalidate/fetchCache segment options removed in that model |
| Edge runtime (Vercel) | standalone | standalone Edge Functions folded onto Vercel Functions | Node recommended for new work; runtime = 'edge' still selectable |
revalidateTag |
revalidateTag(tag) |
revalidateTag(tag) (single-arg deprecating) |
revalidateTag(tag, profile) required (e.g. 'max' or { expire: 0 }); updateTag for read-your-writes |
Common Anti-Patterns
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| Using a Route Handler for an internal mutation triggered from this app's UI | Duplicate type contracts, manual fetch wiring, no progressive enhancement, no built-in revalidation | Use a Server Action |
A Server Component fetch()-ing your own Route Handler to read internal data |
Pointless extra HTTP round-trip (and serialization) to call code already running on the same server; loses direct typing and request-scoped context; can fail during prerender/build (no request server for the internal URL) | Call the data-access function (DB query, service module, cached helper) directly in the Server Component. Route Handlers are for external callers, not for the app fetching itself |
Relying on proxy.ts/middleware auth as the only protection for a Route Handler |
Proxy/middleware can be skipped, mis-matched, or bypassed; direct HTTP callers still reach the route | Enforce authn/authz/validation inside the handler or the data layer; treat middleware as a UX gate, not the security boundary |
Calling request.json() on a webhook before HMAC verification |
The parse can mutate whitespace; signature verification fails | Read request.text(), verify against raw bytes, then JSON.parse |
Letting proxy.ts / middleware.ts run on webhook endpoints by default |
Body buffering/clone (proxyClientMaxBodySize) and upstream transforms can interfere with exact raw-body HMAC verification |
Exclude webhook paths from the matcher; verify the raw body in the handler |
Carrying Next 14's force-dynamic reflex into Next 15+ |
Redundant — GET is already uncached by default; signals a stale mental model that may also wrongly assume caching elsewhere | Rely on the uncached default; add force-static/revalidate only to opt in to caching |
force-static on a GET whose response depends on the request user |
Caches and serves one user's data to everyone | Leave per-user GETs uncached (the default); cache only request-independent responses |
Reading params, cookies(), or headers() synchronously |
Removed in Next 16; throws / type-errors | await them; type the context with RouteContext<'/path/[id]'> |
Reading request.geo / request.ip |
Removed from NextRequest in Next 15 (middleware-only before that) |
geolocation() / ipAddress() from @vercel/functions, or forwarded headers off Vercel |
Putting 'use cache' directly in the handler body |
Not allowed; build error | Extract the cached work to a helper function with 'use cache' + cacheLife |
| Slow webhook ACK because the handler awaits the heavy work inline | Vendor retries; duplicate processing | Durably enqueue the work, then return 200 |
Fire-and-forget queueEventForProcessing(event) (unawaited) before returning 200, or starting an untracked promise after return |
The serverless instance can freeze/die the instant you return; in-flight non-durable work is dropped with no retry, and failures vanish from the request path | await a durable enqueue (DB insert / queue send) before the 200; use after only for droppable post-response work |
Reflecting the request Origin in Access-Control-Allow-Origin without Vary: Origin |
A shared/CDN cache replays one origin's CORS response to a different origin | Send Vary: Origin whenever the CORS headers depend on the request origin |
| Throwing inside the handler and letting it become a generic 500 | Client gets no actionable error | Catch, log, return a structured error envelope with the right status code |
Reflexive Access-Control-Allow-Origin: '*' with credentialed requests |
Browser refuses to send credentials; auth breaks | Allowlist explicit origins; never combine * with credentials |
Putting a route.ts and a page.tsx at the same path |
Build error | Choose one — the URL has one purpose |
Reading request.body as a stream and also trying request.json() |
Body is consumed once | Pick one read method; if you need both raw and parsed, read text and parse yourself |
Mixing App Router Route Handlers with Pages Router pages/api/* patterns (req.body, res.status) |
Different API surfaces; types don't transfer | App Router uses Web Request/Response; rewrite to that shape |
Streaming large/iterator-backed output from an eager start() loop |
Overproduces chunks before the client is ready; unbounded memory pressure | Drive the stream from ReadableStream pull(controller) so backpressure controls production |
Hand-rolling an API subrouter with if/switch conditionals in one catch-all handler |
Hard to read and test once the endpoint set grows | Split by Next filesystem routes, or mount a small router (e.g. Hono) deliberately behind the route.ts entrypoint |
Reaching for runtime = 'edge' "because it's faster" |
Vercel now recommends Node (standalone Edge Functions were folded onto Vercel Functions); Fluid Compute closed the cold-start gap; Edge's capability surface breaks many SDKs | Default to Node; switch to Edge only with a concrete, host-supported reason |
Verification
After applying this skill, verify:
- The handler's caller is genuinely external (mobile, third-party, webhook, cross-origin client) or needs explicit HTTP semantics — otherwise it should be a Server Action or Server Component.
- Every public Route Handler authenticates, authorizes, validates input at runtime, and derives tenant/user identity from server-trusted state — not from "only our frontend calls this."
- No Server Component self-fetches a Route Handler for internal reads — Server Components call the data function directly.
- Each HTTP method that the endpoint should support is a named export; unsupported methods are not exported.
- Body is read exactly once (or read as
text()and parsed manually when raw bytes are needed);request.clone()is not used to double-read a large/streamed body (it forces unbounded buffering). - The Next.js version and Cache Components setting were identified before applying caching guidance; GET is uncached by default (Next 15+);
force-static/revalidate/use cacheis added only when caching is actually wanted and the response is request-independent. -
params,cookies(), andheaders()are awaited (mandatory Next 16); dynamic-segment context is typed (RouteContext<…>or an explicitPromise<…>shape). - No
request.geo/request.ip(removed Next 15) — geolocation/IP comes from@vercel/functionsor forwarded headers, and is treated as non-security-critical. - Webhook handlers verify signature against the raw body before any parsing or business logic, and ACK within the vendor's timeout budget by durably enqueuing the work (awaited DB insert / queue
send) before the 200 — not via an unawaited fire-and-forget call. - Webhook routes are excluded from
proxy.ts/middleware.tsmatchers unless body-size, raw-signature, and timeout implications were tested. - Status codes match the response semantics (201 for created, 202 for accepted-async, 422 for validation failure, 401 vs 403 distinguished).
- Errors are caught and returned with a structured envelope, not allowed to become bare 500s.
- CORS headers — if needed — are explicit allowlists, not
*with credentials, anOPTIONShandler is present,Vary: Originis set when the response varies by request origin, and only needed headers are exposed. - Runtime choice (
edgevsnodejs) is intentional and host-supported, accounting for dependency surface, data locality,maxDuration, and streaming behavior; Node is the default and recommended target, butruntime = 'edge'is still selectable. - Post-response side effects use
afteronly for non-critical, droppable work bounded bymaxDuration; exactly-once webhook processing is handed to a durable queue, notafter. Long work returns202 Acceptedrather than holding the handler open. - Large or iterator-backed streaming responses use
ReadableStreampull()or another backpressure-aware source. - The handler's deployment target is accounted for: no reliance on shared in-memory state or a persistent local filesystem on serverless; only a
force-staticGET is used underoutput: 'export'; persistent/WebSocket connections are not modeled as a Route Handler.
Grounding Sources
- Next.js docs — Route Handlers. The canonical reference for the
route.tsconvention (verified against Next 16.2, 2026-06-07: GET uncached by default, opt in withforce-static; Cache Componentsuse cachemodel;RouteContexthelper). - Next.js docs —
route.jsfile convention /RouteContext. The per-route export contract and the generated typed-params helper. - Next.js docs — Upgrading to Version 15. The GET-caching-default change and the
NextRequestgeo/ip removal. - Next.js docs — Upgrading to Version 16. Async request APIs now mandatory;
middleware→proxy;cacheComponents;revalidateTag/updateTag; PPR viacacheComponents. - Next.js docs — Route Segment Config.
dynamic,revalidate,runtime,maxDuration,preferredRegion,dynamicParams, and which knobs survive under Cache Components. - Next.js docs — Caching (Cache Components) and
use cache/cacheLife/cacheTag. The prerender model for Route Handlers. - Next.js docs —
revalidateTag. The current two-argument signature. - Next.js docs —
after. Post-response side-effect scheduling (stable since 15.1,unstable_afterin 15.0); runs viawaitUntil, bounded bymaxDuration, not a queue replacement. - Next.js docs —
proxy.ts(file convention),proxyClientMaxBodySize, and middleware-to-proxy. The rename, Node-only runtime, body-buffering ceiling, and "return a response directly." - Next.js docs — Backend for Frontend. Frames Route Handlers as the App Router's BFF/public-endpoint layer; the Server-Component-self-fetch anti-pattern and the lambda shared-state / static-export deployment caveats.
- Vercel docs —
@vercel/functionsAPI reference.geolocation()/ipAddress()— replacements for the removedNextRequest.geo/.ip. - Vercel docs — Edge Runtime, Fluid compute, Streaming functions, and the changelog Edge Middleware and Edge Functions are now powered by Vercel Functions. Standalone Edge Functions folded onto Vercel Functions; Node recommended; Edge runtime still selectable; Fluid Compute cold-start mitigations. (Verify the exact wording/effective date against the live changelog before quoting it — the two enrich models cited conflicting dates.)
- MDN — Fetch API: Request and Response, plus
Request.bodyUsed,Request.clone(), andReadableStream. The Web-standard interface; single-read body contract;clone()buffering cost;pull()backpressure. - RFC 9110 — HTTP Semantics. Method semantics, status code semantics, header semantics. The contract Route Handlers honor.
- Stripe docs — Verifying webhook signatures. The canonical example of the raw-body / verify-before-parse pattern that drives Route Handler webhook design.
- WHATWG Streams — Streams Living Standard. The
ReadableStreaminterface used for streaming responses. - Hono docs — Next.js integration. Optional in-handler subrouting (
app/api/[[...route]]/route.tsexportingGET = handle(app)) that composes inside — not instead of — the Route Handler entrypoint.
Do NOT Use When
| Instead of this skill | Use | Why |
|---|---|---|
| Designing an internal mutation triggered only from this app's UI | server-actions-design |
Server Actions are the right surface for in-app mutations — one declaration, no wire format, progressive enhancement. |
| Reading application data inside a Server Component during render | call the data source directly (DB/service/cached helper) | A Server Component self-fetching its own Route Handler adds an HTTP round-trip and can fail during prerender/build — there is no request server for the internal URL. |
| Designing the broader REST/RPC contract: resources, versioning, pagination, error envelopes | api-design |
api-design owns the contract; this skill owns the Next.js implementation surface that hosts the contract. |
| Understanding what each HTTP method or status code means in the abstract | http-semantics |
http-semantics owns the protocol semantics; this skill owns honoring them in App Router. |
Adding auth, locale, or header injection across many routes (middleware.ts / proxy.ts) |
middleware-patterns |
middleware/proxy runs once before route resolution and applies to many routes; Route Handlers run per-route per-method. |
| The full webhook reliability story: idempotency, retries, dead-letter queues, replay protection | webhook-integration |
webhook-integration owns the cross-vendor reliability discipline; this skill covers the endpoint surface only. |
| The cross-cutting streaming model: Web Streams, SSE, RSC streaming, backpressure | streaming-architecture |
streaming-architecture covers streaming as a concept; this skill covers the Route Handler streaming surface specifically. |
The serialization/directive mechanics of 'use client' and 'use server' |
client-server-boundary |
Different boundary — client-server-boundary is for the bundler's component split, not for HTTP endpoints. |
Skill Graph context
Classification
- Subject:
backend-engineering - Public:
true - Domain:
engineering/backend - Scope: Use when designing or reviewing Next.js Route Handlers (the
route.ts/route.jsfile convention in App Router): when a Route Handler is the right public HTTP endpoint surface vs Server Actions or Server Components, the HTTP-method-as-export contract (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), the Web-standard Request/Response interface (no Nodereq/res), the body-parsing primitives (request.json/formData/text/blob/arrayBuffer) and one-shot body consumption, the default caching behavior of GET responses (NOT cached by default since Next 15; opt IN viadynamic = 'force-static') and the Cache Componentsuse cachemodel, asyncparams/cookies/headersandRouteContexttyped params, dynamic segments and search params, the rule that every Route Handler is a PUBLIC surface that must authenticate/authorize/validate inside the handler, manual CORS, runtime/deployment selection (Node default and recommended after Vercel folded standalone Edge Functions onto Vercel Functions; the Edge runtime still selectable;maxDuration/preferredRegion/static-export limits), streaming viaReadableStreamincludingpull()backpressure, status-code and header discipline, error responses, webhook-style handlers (signature verify before parse, ACK fast,after()for short post-response work), and the design rule that a Route Handler is the right surface when the caller is not your own Next.js UI. Do NOT use for internal mutations triggered from your own UI (use server-actions-design), for render-time reads from your own Server Components (call the data source directly), for the broader REST/contract/versioning discipline (use api-design), for HTTP method/status semantics in the abstract (use http-semantics), for request preprocessing across all routes — nowproxy.ts/middleware.ts(use middleware-patterns), or for the broader webhook reliability story — HMAC verification, idempotency, retries, queue handoff (use webhook-integration).
When to use
- design the route.ts that receives a Stripe webhook — verify signature before parsing the body, return 200 immediately, queue the heavy work
- decide whether a 'export user data' endpoint should be a Route Handler or a Server Action
- add CORS to a Route Handler that mobile clients call from a different origin
- cache a GET Route Handler that returns rarely-changing data (force-static or use cache)
- return a streaming binary response (PDF generation, large CSV export) from a Route Handler
- Triggers:
how do I expose an API endpoint in Next.js App Router,when should I use a Route Handler instead of a Server Action,why is my Route Handler GET not cached anymore,how do I statically cache a GET Route Handler in Next 15 or 16,how do I type dynamic params in route.ts,how do I receive a webhook in Next.js without breaking raw body verification,how do I ACK a webhook fast and run short work after the response,how do I return a streaming response from an API route,how do I stream a large response with ReadableStream backpressure,how do I parse a JSON body in route.ts,how do I set CORS headers on a Next.js API route,Edge runtime vs Node runtime for an API route
Not for
- design an internal create-comment form mutation triggered only from this app's UI (use server-actions-design)
- read application data inside a Server Component during render (call the data source directly, not a self-fetch to your own Route Handler)
- define the REST contract and resource model for a v2 public API (use api-design)
- explain what an HTTP 422 means vs 400 (use http-semantics)
- add an auth check that runs before every protected route in proxy.ts/middleware.ts (use middleware-patterns)
- design the idempotency-key + retry + dead-letter-queue strategy for a webhook (use webhook-integration)
- design the resource model, versioning, pagination, or error envelope of an HTTP API (use api-design)
- decide what an HTTP method, status code, or header should mean per RFC 9110 (use http-semantics)
- design signature verification, idempotency keys, retry semantics, or dead-letter queues for vendor webhooks (use webhook-integration)
- design a cross-cutting streaming model with Web Streams, SSE, or backpressure (use streaming-architecture)
Related skills
- Verify with:
code-review,api-design - Related:
http-semantics,webhook-integration,streaming-architecture,client-server-boundary,server-actions-design,api-design,middleware-patterns
Concept
- Mental model: |
- Purpose: |
- Boundary: |
- Analogy: A Route Handler is to a Next.js app what a service window at a government office is to its workflow — different windows handle different services (
GET /posts,POST /comments); each window has a posted sign saying which forms it accepts and what stamps it returns; you do not walk into the back office (Server Action) unless you work there. The window is the contract: filesystem path = window number, export name = service offered, function body = the clerk's actual work. - Common misconception: |
Keywords
Next.js Route Handler,route.ts file,GET Route Handler caching,Cache Components use cache,RouteContext typed params,NextRequest geo ip removed,request.json formData parsing,after Route Handler webhook,ReadableStream pull backpressure,Edge runtime vs Node runtime