# Route Handler Design

> 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).

- Skill: `jacob-balslev/route-handler-design` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add jacob-balslev/route-handler-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jacob-balslev/route-handler-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: jacob-balslev (https://skillmd.com/u/jacob-balslev)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jacob-balslev/route-handler-design

---

# 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 in `route.ts`.
- **Custom server handlers**: middleware, edge functions — partly absorbed by `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.

## 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

```ts
// 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

```ts
// 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

```ts
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 `GET` handler that didn't read request-scoped sources was *statically cached by default* and you opted out with `dynamic = 'force-dynamic'`. **Since Next 15 the default flipped: Route Handlers are NOT cached by default.** Every method, including `GET`, runs at request time unless you explicitly opt in. Code and advice written for Next 14 (including "remember to add `force-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:

```ts
// 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](https://nextjs.org/docs/app/getting-started/caching) 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 like `Math.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 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:

```ts
// 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:

```ts
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.geo` and `request.ip` were **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 from `request.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.

```ts
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.

```ts
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**.

```ts
// 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()`**, not `request.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 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`).
- **`runtime = 'nodejs'`** — some vendor SDKs need Node-only crypto APIs (Stripe's `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.

### 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`](https://nextjs.org/docs/app/api-reference/functions/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:

```ts
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:

```ts
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`:

```ts
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

```ts
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

```ts
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, 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.

### 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 static `GET` works** — it must declare `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.)
- **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 

…(truncated)
