route-handler-design — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited route-handler-design (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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).
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.
The App Router collapsed three things that used to be distinct in the Pages Router:
page.tsx./api/* — moved to Route Handlers in route.ts.middleware.ts (renamed proxy.ts in 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.
| 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.
// 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.
// 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.
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.)
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.
This is the most-changed area of the Route Handler surface — verify which Next major you target. In Next 14, aGEThandler 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 60sPOST, 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.
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:
cookies(), headers(), connection(), request.url, request.headers, a DB query, the filesystem, or non-deterministic ops like Math.random()) terminates prerendering and the handler runs at request time.use cache helper with a cacheLife profile. 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.
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 from `NextRequest` in 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:
>
```ts 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.
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.
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.json() — you need the raw bytes for HMAC; parsing first would mutate whitespace and break verification.send). A bare unawaited queueEventForProcessing(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. Use after (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 is webhook-integration).constructEvent does 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.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.
after vs a durable queueNext 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.
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.
Honor HTTP semantics (see http-semantics for the full discipline):
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.
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:
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.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, and `runtime = '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.
Where a Route Handler can run depends on how the app is deployed — design with the target in mind:
export 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/middleware is likewise unsupported in static export.)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.202 Accepted plus an operation/status resource (see api-design) or enqueue a job/webhook pipeline, rather than blocking the response past maxDuration. after() covers short post-response work only, bounded by maxDuration; it is not a durable queue.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 |
| 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 |
After applying this skill, verify:
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).force-static/revalidate/use cache is added only when caching is actually wanted and the response is request-independent.params, cookies(), and headers() are awaited (mandatory Next 16); dynamic-segment context is typed (RouteContext<…> or an explicit Promise<…> shape).request.geo / request.ip (removed Next 15) — geolocation/IP comes from @vercel/functions or forwarded headers, and is treated as non-security-critical.send) before the 200 — not via an unawaited fire-and-forget call.proxy.ts/middleware.ts matchers unless body-size, raw-signature, and timeout implications were tested.* with credentials, an OPTIONS handler is present, Vary: Origin is set when the response varies by request origin, and only needed headers are exposed.edge vs nodejs) is intentional and host-supported, accounting for dependency surface, data locality, maxDuration, and streaming behavior; Node is the default and recommended target, but runtime = 'edge' is still selectable.after only for non-critical, droppable work bounded by maxDuration; exactly-once webhook processing is handed to a durable queue, not after. Long work returns 202 Accepted rather than holding the handler open.ReadableStream pull() or another backpressure-aware source.force-static GET is used under output: 'export'; persistent/WebSocket connections are not modeled as a Route Handler.route.ts convention (verified against Next 16.2, 2026-06-07: GET uncached by default, opt in with force-static; Cache Components use cache model; RouteContext helper).route.js file convention / RouteContext. The per-route export contract and the generated typed-params helper.NextRequest geo/ip removal.middleware→proxy; cacheComponents; revalidateTag/updateTag; PPR via cacheComponents.dynamic, revalidate, runtime, maxDuration, preferredRegion, dynamicParams, and which knobs survive under Cache Components.use cache / cacheLife / cacheTag. The prerender model for Route Handlers.revalidateTag. The current two-argument signature.after. Post-response side-effect scheduling (stable since 15.1, unstable_after in 15.0); runs via waitUntil, bounded by maxDuration, not a queue replacement.proxy.ts (file convention), proxyClientMaxBodySize, and middleware-to-proxy. The rename, Node-only runtime, body-buffering ceiling, and "return a response directly."@vercel/functions API reference. geolocation() / ipAddress() — replacements for the removed NextRequest.geo/.ip.Request.bodyUsed, Request.clone(), and ReadableStream. The Web-standard interface; single-read body contract; clone() buffering cost; pull() backpressure.ReadableStream interface used for streaming responses.app/api/[[...route]]/route.ts exporting GET = handle(app)) that composes inside — not instead of — the Route Handler entrypoint.| 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:start (generated — do not edit by hand) -->
Classification
backend-engineeringtrueengineering/backendroute.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~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.