Next.js NotesRohit’s interview study guide
Chapter 08

Route Handlers and Proxy

HTTP endpoints in the App Router with route.ts, how they are cached, streaming and webhooks, proxy.ts (the file that was middleware.ts before Next.js 16) for redirects, rewrites and headers, and after() for work that shouldn't make the user wait.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Route Handlers

Added

A Route Handler is a route.ts file inside app/ that answers HTTP requests directly. The folder path is the URL, as it is for pages. Each function the file exports is named after an HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), and it speaks the web-standard Fetch API: a Request comes in, you return a Response. There's no React, no layout, no HTML unless you write it yourself. It's what you use for a JSON API that a mobile app or a third party calls, a webhook receiver, a file download, an RSS feed, or an OAuth callback.

The mental model: the app/ folder is a building and every URL is a door. Behind a page.tsx door is a furnished room: layouts, React, HTML, client-side navigation. A route.ts door is a service hatch: no decoration, you pass a request through and get exactly the bytes you hand back. That's why one folder can't have both. The URL /hello can lead to a room or a hatch, not both, and Next.js refuses to build if you try.

If you know Express, the shape is familiar but the API isn't Express's. There's no req, res, next: you get a Request (Next.js passes a NextRequest, a subclass with nextUrl and cookies helpers) and you return the response instead of calling res.send(). If you know the Pages Router, this replaces pages/api/* (see API Routes); the two do the same job and you don't need both.

Here is a small to-do API: a collection route first, then the in-memory store it imports (standing in for a database), then an item route with a dynamic segment.

app/api/todos/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { addTodo, listTodos } from "@/lib/todos";

// GET /api/todos?done=true
export async function GET(request: NextRequest) {
  const done = request.nextUrl.searchParams.get("done");
  const todos = listTodos(done === null ? undefined : done === "true");
  return NextResponse.json({ todos });
}

// POST /api/todos  {"title": "..."}
export async function POST(request: Request) {
  if (!request.headers.get("content-type")?.startsWith("application/json")) {
    return NextResponse.json({ error: "Send application/json" }, { status: 415 });
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Malformed JSON" }, { status: 400 });
  }

  const title = (body as { title?: unknown } | null)?.title;
  if (typeof title !== "string" || title.trim() === "") {
    return NextResponse.json({ error: "title is required" }, { status: 422 });
  }

  const todo = addTodo(title.trim());
  return NextResponse.json(todo, {
    status: 201,
    headers: { Location: `/api/todos/${todo.id}` },
  });
}
What’s happening
  1. The file lives at app/api/todos/route.ts, so it answers /api/todos. The api/ folder is only a convention; a route.ts can sit anywhere in app/ as long as that folder has no page.tsx.
  2. GET receives a NextRequest. request.nextUrl is a parsed URL, so searchParams.get("done") gives "true", "false" or null. null means "no filter", which is why it maps to undefined rather than false: curl "localhost:4508/api/todos?done=false" returned only {"id":2,"title":"Ship the API","done":false}.
  3. NextResponse.json(data, init) is Response.json with Next.js's extras. It serialises the object and sets content-type: application/json. POST uses a plain Request type, which is enough when you only need standard methods.
  4. POST checks the content type first (415), then parses (request.json() throws on bad JSON, so that's a 400), then validates the shape (422). Each failure returns early with its own status. Without the try, malformed JSON would throw out of the handler and the client would get an empty 500.
  5. On success it returns 201 Created with a Location header pointing at the new item. Status and headers go in the second argument, exactly as with new Response(body, { status, headers }).
  6. Nothing here is Next.js-specific except the file location and NextResponse. That's the point of Route Handlers: they're the same Request/Response objects you'd use in a service worker, Deno, Bun or Cloudflare Workers.
AdvancedThe in-memory store behind the API

The store it imports is a plain module:

lib/todos.ts
export type Todo = { id: number; title: string; done: boolean };

const todos: Todo[] = [
  { id: 1, title: "Read the route.ts docs", done: true },
  { id: 2, title: "Ship the API", done: false },
];
let nextId = 3;

export function listTodos(done?: boolean) {
  return done === undefined ? todos : todos.filter((t) => t.done === done);
}

export function getTodo(id: number) {
  return todos.find((t) => t.id === id);
}

export function addTodo(title: string) {
  const todo = { id: nextId++, title, done: false };
  todos.push(todo);
  return todo;
}

export function updateTodo(id: number, patch: Partial<Omit<Todo, "id">>) {
  const todo = getTodo(id);
  if (todo) Object.assign(todo, patch);
  return todo;
}

export function deleteTodo(id: number) {
  const i = todos.findIndex((t) => t.id === id);
  if (i >= 0) todos.splice(i, 1);
  return i >= 0;
}
What’s happening
  1. It's a module-level array plus an id counter, and every exported function reads or changes that one array.
  2. listTodos(undefined) returns everything and listTodos(false) filters, which is why the route maps a missing ?done to undefined rather than false.
  3. updateTodo uses Object.assign on the object it found, so the change is visible to every later call. deleteTodo returns false when nothing matched, so the item route can answer 404.
  4. It stands in for a database. It works here because one next start process holds one copy of the module; the gotcha at the end of this topic explains why that breaks on serverless.
AdvancedStatus codes from the running API

This is what the running app returned (next build && next start -p 4508, then curl). Each command is shown before its output:

Output
$ curl -s -i localhost:4508/api/todos
HTTP/1.1 200 OK
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Transfer-Encoding: chunked

{"todos":[{"id":1,"title":"Read the route.ts docs","done":true},{"id":2,"title":"Ship the API","done":false}]}

$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{"title":"Write chapter 8"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/todos/3

{"id":3,"title":"Write chapter 8","done":false}

$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: text/plain' -d 'hi'
HTTP/1.1 415 Unsupported Media Type

{"error":"Send application/json"}

$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{"title":'
HTTP/1.1 400 Bad Request

{"error":"Malformed JSON"}

$ curl -s -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{}' -w ' %{http_code}'
{"error":"title is required"} 422
AdvancedNo Cache-Control, and an extra Vary

(Date, Connection and Keep-Alive headers are left out here and below.) Note two things in the headers. There's no Cache-Control at all on a dynamic Route Handler response; Next.js doesn't add one, so if a CDN sits in front, set it yourself. And Next.js adds vary: rsc, next-router-state-tree, … to every response, including API responses, because the same URL can serve HTML or an RSC payload for pages.

AdvancedDynamic segments and async params

Dynamic segments and params

The item route lives in a [id] folder. Its handlers get a second argument, the context, whose params is a Promise (since Next.js 15).

app/api/todos/[id]/route.ts
import { NextResponse } from "next/server";
import { deleteTodo, getTodo, updateTodo } from "@/lib/todos";

export async function GET(_request: Request, ctx: RouteContext<"/api/todos/[id]">) {
  const { id } = await ctx.params; // a Promise since Next.js 15
  const todo = getTodo(Number(id));
  if (!todo) return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
  return NextResponse.json(todo);
}

export async function PATCH(request: Request, ctx: RouteContext<"/api/todos/[id]">) {
  const { id } = await ctx.params;
  const patch = (await request.json()) as { title?: string; done?: boolean };
  const todo = updateTodo(Number(id), patch);
  if (!todo) return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
  return NextResponse.json(todo);
}

export async function DELETE(_request: Request, ctx: RouteContext<"/api/todos/[id]">) {
  const { id } = await ctx.params;
  if (!deleteTodo(Number(id))) {
    return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
  }
  return new Response(null, { status: 204 });
}
What’s happening
  1. RouteContext<"/api/todos/[id]"> is a global type that next build (and next dev / next typegen) generates from your folders, so ctx.params is typed as Promise<{ id: string }> without writing it by hand. Misspell the route string and type checking fails.
  2. await ctx.params gives { id: "3" }. Params are always strings (or string arrays for [...slug]), so Number(id) converts it. /api/todos/abc gives Number("abc") → NaN, which matches nothing, so it's a clean 404: {"error":"No todo abc"} 404.
  3. PATCH reads the body with request.json() and merges it. It has no try, on purpose for this demo: sending -d 'oops' made request.json() throw, and the client got HTTP/1.1 500 Internal Server Error with an empty body, while the server log printed ⨯ SyntaxError: Unexpected token 'o', "oops" is not valid JSON. Unhandled errors never leak their message to the client, but they also give the client nothing useful, so validate input and return a 400 yourself.
  4. DELETE returns new Response(null, { status: 204 }). A 204 must not have a body, so it's built with the plain Response constructor; NextResponse.json() always writes a body.
  5. The item created by POST /api/todos in the previous example was visible here: GET /api/todos/3 returned {"id":3,"title":"Write chapter 8","done":false} 200. Both route files import the same lib/todos.ts module, and under one next start process they shared one copy of it. That's true of a single long-running server, not of serverless platforms, where each instance has its own memory (see the gotcha below).
Output
$ curl -s -X PATCH localhost:4508/api/todos/3 -H 'content-type: application/json' -d '{"done":true}' -w ' %{http_code}\n'
{"id":3,"title":"Write chapter 8","done":true} 200
$ curl -s -i -X DELETE localhost:4508/api/todos/3
HTTP/1.1 204 No Content

$ curl -s -X DELETE localhost:4508/api/todos/3 -w ' %{http_code}\n'
{"error":"No todo 3"} 404
Advanced405, automatic OPTIONS and HEAD

What Next.js does with methods you didn't write

You only exported some methods. The running app showed what happens to the rest:

Output
$ curl -s -i -X PUT localhost:4508/api/todos
HTTP/1.1 405 Method Not Allowed

$ curl -s -i -X OPTIONS localhost:4508/api/todos
HTTP/1.1 204 No Content
allow: GET, HEAD, OPTIONS, POST

$ curl -s -i -X OPTIONS localhost:4508/api/todos/1
HTTP/1.1 204 No Content
allow: DELETE, GET, HEAD, OPTIONS, PATCH

$ curl -s -I localhost:4508/api/todos
HTTP/1.1 200 OK
content-type: application/json
What’s happening
  1. PUT isn't exported from app/api/todos/route.ts, so Next.js answers 405 Method Not Allowed without calling your code. On this dynamic route the 405 carried no Allow header.
  2. OPTIONS isn't exported either, but Next.js implements it for you: a 204 whose allow header lists the methods the file exports, plus HEAD and OPTIONS. That's what a CORS preflight or an API client asks for. Export your own OPTIONS when the preflight needs Access-Control-* headers.
  3. HEAD isn't exported, yet curl -I got a 200 with the content-type of the GET response. Next.js runs your GET and drops the body: in a separate test app I logged from a GET handler, and a curl -I request printed the log line.
  4. So the methods you write are the only ones that run your logic, and the protocol-level ones (HEAD, OPTIONS, 405) are handled for you.
AdvancedCookies, headers and redirect()

Cookies, headers and redirects

You can read request data two ways: from the request object, or with the cookies() and headers() functions from next/headers that pages use too. To set a cookie, put it on the response.

app/api/prefs/route.ts
import { cookies } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  // Two ways to read the same cookie
  const fromRequest = request.cookies.get("theme")?.value;
  const fromHelper = (await cookies()).get("theme")?.value;
  return NextResponse.json({ fromRequest, fromHelper });
}

export async function POST(request: NextRequest) {
  const { theme } = (await request.json()) as { theme: string };
  const response = NextResponse.json({ saved: theme });
  response.cookies.set("theme", theme, { httpOnly: true, sameSite: "lax", path: "/" });
  return response;
}
What’s happening
  1. request.cookies.get("theme") reads the parsed Cookie header of this request; NextRequest adds the cookies helper to the standard Request.
  2. (await cookies()).get("theme") reads the same thing through the request-scoped API. It's async since Next.js 15, like params. Both returned "dark" for curl -H 'cookie: theme=dark': {"fromRequest":"dark","fromHelper":"dark"}. With no cookie both were undefined, which JSON.stringify drops, so the body was {}.
  3. POST builds the response first, then calls response.cookies.set(…). The client received set-cookie: theme=dark; Path=/; HttpOnly; SameSite=lax. (await cookies()).set(…) also works in a Route Handler; the response-object form makes it obvious which response carries the cookie.
  4. Use request.cookies when you already have the request (proxy, Route Handlers). Use cookies() in code that doesn't get the request passed in: Server Components, Server Actions, helpers in your data layer.

redirect() from next/navigation works in a Route Handler too. It throws a special error that Next.js turns into a 307:

app/old-api/route.ts
import { redirect } from "next/navigation";

export function GET() {
  redirect("/api/todos");
}
What’s happening
  1. redirect() never returns. It throws an internal NEXT_REDIRECT error, and Next.js catches it and sends 307 Temporary Redirect with location: /api/todos. Don't wrap it in a try/catch that swallows errors, or the redirect is swallowed too.
  2. This handler reads nothing from the request, so the build prerendered it: the route table below shows ○ /old-api. The running server answered curl -i localhost:4508/old-api with 307, location: /api/todos, x-nextjs-cache: HIT and cache-control: s-maxage=31536000: a stored redirect, cacheable for a year. That's fine for a permanent move; if the target depended on the request you'd read the request and the route would become dynamic. The next topic is about exactly this.
  3. A prerendered GET route also answers other methods from the stored copy: curl -X POST localhost:4508/old-api got 405 Method Not Allowed with Allow: GET and Allow: HEAD.

The build output for this app:

next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/prefs
├ ƒ /api/todos
├ ƒ /api/todos/[id]
└ ○ /old-api


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedNextRequest and NextResponse cheat sheet

NextRequest and NextResponse at a glance

You want to…Use
Read the path or query stringrequest.nextUrl.pathname, request.nextUrl.searchParams
Read a cookierequest.cookies.get(name) or (await cookies()).get(name)
Read a headerrequest.headers.get(name) or (await headers()).get(name)
Read the bodyawait request.json(), .text(), .formData(), .arrayBuffer(): once only
Return JSON with a statusNextResponse.json(data, { status, headers }) or Response.json(…)
Return no bodynew Response(null, { status: 204 })
Set a cookieresponse.cookies.set(name, value, options)
RedirectNextResponse.redirect(new URL("/x", request.url)) or redirect("/x")
Streamnew Response(readableStream, { headers }) (see Streaming responses and webhooks)

NextRequest and NextResponse are subclasses of the standard classes, so you can pass a NextRequest to anything expecting a Request and return a Response wherever a NextResponse is accepted. Before Next.js 15, NextRequest also had request.ip and request.geo; 15 removed them (your host's headers, such as x-forwarded-for, replace them).

AdvancedEdge case: route.ts and page.tsx clash

Route Handlers and pages can't share a folder

Each URL is either a page or a route. I put both files in app/hello/ and ran next build:

Output
$ next build
> Build error occurred
Error: Turbopack build failed with 1 error:
./.sandbox/nextjs/ch08/conflict/src/app
Error: An issue occurred while preparing your Next.js app
Conflicting route and page at /hello: route at /hello/route and page at /hello/page

This holds even when the route.ts exports only POST (as it did here) and the page only needs GET: a route.ts takes over every method for its URL. Put the API one level down (app/hello/api/route.ts) or under app/api/. Route Handlers also don't use layouts, loading.tsx or error.tsx, and <Link> navigations don't target them: they're plain HTTP.

#Caching and Route Handlers

Added

A Route Handler is cached on the same terms as a page, because under the hood Next.js treats a GET handler as something it can prerender at build time and serve as a stored file. The question the build asks is the same one it asks of a page: can I run this now, with no request, and get an answer that will still be right later?

With Cache Components on (the default for new Next.js 16 apps), the rule is: a GET handler is prerendered if it runs to completion without touching anything request-specific or non-deterministic, and it's dynamic (runs on every request) as soon as it does. Request-specific means request.url, request.headers, request.cookies, the body, or cookies()/headers()/connection(). Non-deterministic means Math.random(), new Date(), network calls, database queries and async file reads. To keep slow data in the prerendered response anyway, move it into a function marked 'use cache'.

Picture the build running every GET handler with a stopwatch and no request. If the handler finishes with only constants, the result is written to disk and served forever (○). If it reaches for the clock, the dice, the network or the request, the build stops and leaves the route to run live (ƒ). 'use cache' is how you tell the build "this slow part is fine to store; here's how long it stays fresh".

app/api/info/route.ts
export async function GET() {
  return Response.json({ service: "catalog", version: 3 });
}
app/api/now/route.ts
export async function GET() {
  return Response.json({ now: new Date().toISOString() });
}
app/api/random/route.ts
export async function GET() {
  return Response.json({ n: Math.random() });
}
app/api/whoami/route.ts
import { headers } from "next/headers";

export async function GET() {
  const userAgent = (await headers()).get("user-agent");
  return Response.json({ userAgent });
}
app/api/echo/route.ts
export async function GET(request: Request) {
  return Response.json({ url: request.url });
}
What’s happening
  1. /api/info returns a constant object and never looks at the request, so the build runs it once and stores the JSON. The route table below shows ○ /api/info.
  2. /api/now calls new Date(). A timestamp taken at build time would be wrong at every later moment, so prerendering stops there and the route is ƒ. There was no build warning: unlike a page, where reading the clock outside 'use cache' or <Suspense> is a build error under Cache Components, a Route Handler just becomes dynamic.
  3. /api/random is ƒ for the same reason: Math.random() is non-deterministic.
  4. /api/whoami awaits headers(), a request-time API, so it's ƒ. /api/echo reads request.url, also request data, so it's ƒ too. Merely accepting the request parameter costs nothing: a handler declared as GET(request: Request) that never read it was still ○ in a test build. Reading from it is what counts.
  5. The lesson is that you can't tell a static handler from a dynamic one by its signature. Read the build's route table, or check the response headers below.
AdvancedRoute table: which handlers were prerendered

The route table for this app (the files further down this topic are part of it too):

next build
Route (app)          Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/echo
├ ○ /api/info
├ ƒ /api/likes
├ ƒ /api/now
├   /api/posts/[id]
│ ├ ● /api/posts/1           1h      1d
│ └ ● /api/posts/2           1h      1d
├ ○ /api/products           10s      1h
├ ƒ /api/random
├ ƒ /api/revalidate
├ ○ /api/views
└ ƒ /api/whoami


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
AdvancedCache headers on static vs dynamic handlers

And what next start sent back (curl -s -D - localhost:4508/api/<name>):

Output
/api/info     200  x-nextjs-cache: HIT   cache-control: s-maxage=31536000   {"service":"catalog","version":3}
/api/now      200  (no cache headers)                                       {"now":"2026-10-25T18:02:49.145Z"}
/api/random   200  (no cache headers)                                       {"n":0.16287481104057833}
/api/whoami   200  (no cache headers)                                       {"userAgent":"curl/8.7.1"}
/api/echo     200  (no cache headers)                                       {"url":"http://localhost:4508/api/echo"}
AdvancedReading x-nextjs-cache and s-maxage

x-nextjs-cache: HIT means the response came from the stored prerender, and s-maxage=31536000 (one year) tells a CDN it may keep it that long. Dynamic responses carry neither.

AdvancedCaching slow data with 'use cache'

Most real handlers read a database, which would make them dynamic. Put the slow part in a function with 'use cache' and give it a lifetime with cacheLife (see 'use cache' and cacheLife for the details of both).

lib/catalog.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getProducts() {
  "use cache";
  cacheLife({ revalidate: 10, expire: 3600 });
  cacheTag("products");
  await new Promise((r) => setTimeout(r, 300)); // pretend this is a slow database query
  return {
    products: [
      { id: 1, name: "Kettle" },
      { id: 2, name: "Teapot" },
    ],
    fetchedAt: new Date().toISOString(),
  };
}
app/api/products/route.ts
import { getProducts } from "@/lib/catalog";

export async function GET() {
  return Response.json(await getProducts());
}
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("products", "max");
  return Response.json({ revalidated: "products" });
}
What’s happening
  1. 'use cache' can't go directly in the handler body (the docs say so), so it lives in getProducts(). Inside a cached function, new Date() and the fake 300 ms query are allowed: their result is what gets stored.
  2. Because the only slow work is cached, the build could run the handler to the end. The route table shows ○ /api/products 10s 1h: prerendered, revalidate after 10 seconds, expire after 1 hour. Those numbers come straight from cacheLife({ revalidate: 10, expire: 3600 }).
  3. At request time the server sent cache-control: s-maxage=10, stale-while-revalidate=3590, the same lifetimes for a CDN (3590 = 3600 − 10).
  4. cacheTag("products") labels the entry, and revalidateTag("products", "max") in the POST handler marks it stale on demand, for example when a CMS webhook says a product changed. The second argument, a cacheLife profile, is new in Next.js 16 (the one-argument form is deprecated and a TypeScript error); "max", the recommended value, gives stale-while-revalidate behaviour.
  5. If I'd written cacheLife({ stale: 0, revalidate: 10, expire: 3600 }), the route became ƒ in my first build. The docs explain it: a stale under 30 seconds (or revalidate: 0, or an expire under 5 minutes) excludes the cache from prerenders. Short-lived caches still work, but at request time.

I watched the timing with curl, one request per second (date -u before each one):

Output
18:02:55  x-nextjs-cache: HIT    "fetchedAt":"2026-10-25T18:02:46.051Z"
18:02:56  x-nextjs-cache: STALE  "fetchedAt":"2026-10-25T18:02:46.051Z"
18:02:57  x-nextjs-cache: HIT    "fetchedAt":"2026-10-25T18:02:56.963Z"
…
$ curl -s -X POST localhost:4508/api/revalidate
{"revalidated":"products"}
18:04:17  x-nextjs-cache: STALE  "fetchedAt":"2026-10-25T18:04:09.566Z"
18:04:17  x-nextjs-cache: HIT    "fetchedAt":"2026-10-25T18:04:17.519Z"
What’s happening
  1. The build stored the response at 18:02:46. Nine seconds later it's still fresh: HIT, with the build-time fetchedAt.
  2. At 18:02:56, more than 10 seconds after it was stored, the server still answers immediately with the old body but labels it STALE, and starts regenerating in the background. That's stale-while-revalidate: nobody waits for the 300 ms query.
  3. The next request gets the regenerated body (fetchedAt 18:02:56.963) as a HIT.
  4. After POST /api/revalidate, the very next GET was STALE (old body, regeneration started) and the one after had a fresh fetchedAt. With the "max" profile, on-demand revalidation behaves like time-based revalidation: serve stale once, then fresh.
  5. The regenerated entry also survived a server restart: after restarting next start, the first response still had fetchedAt 18:02:56.963, because next start writes regenerated prerenders to .next on disk.
AdvancedgenerateStaticParams for API routes

generateStaticParams for dynamic API routes

A dynamic Route Handler can prerender a known set of params, like a dynamic page:

app/api/posts/[id]/route.ts
import { cacheLife } from "next/cache";

export async function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }];
}

async function getPost(id: string) {
  "use cache";
  cacheLife("hours");
  return { id, title: `Post ${id}`, renderedAt: new Date().toISOString() };
}

export async function GET(_request: Request, ctx: RouteContext<"/api/posts/[id]">) {
  const { id } = await ctx.params;
  return Response.json(await getPost(id));
}
What’s happening
  1. generateStaticParams returns [{ id: "1" }, { id: "2" }], so the build calls GET twice and stores both responses: ● /api/posts/1 and ● /api/posts/2 with the hours profile's 1h revalidate and 1d expire.
  2. /api/posts/1 answered x-nextjs-cache: HIT with a renderedAt from the build (18:02:45.660).
  3. /api/posts/3 wasn't in the list. The first request rendered it on demand (x-nextjs-cache: MISS, renderedAt 18:02:49.231), and the second request for it was a HIT with the same renderedAt. Unlisted params are rendered once and then cached like the listed ones.
  4. The headers for both were cache-control: s-maxage=3600, stale-while-revalidate=82800: the hours profile (1 hour fresh, 1 day = 86 400 s total).
Two files, one counter

Both files import the same in-memory counter. What does GET /api/views return after three POST /api/likes?

lib/likes.ts
let likes = 0;
export const getLikes = () => likes;
export const addLike = () => ++likes;
app/api/likes/route.ts
import { addLike, getLikes } from "@/lib/likes";

export async function GET() {
  return Response.json({ likes: getLikes() });
}

export async function POST() {
  return Response.json({ likes: addLike() });
}
app/api/views/route.ts
import { getLikes } from "@/lib/likes";

// Same counter, but this file only has GET
export async function GET() {
  return Response.json({ likes: getLikes() });
}
Show answer

GET /api/likes returns {"likes":3}, but GET /api/views returns {"likes":0}, forever.

Output
$ for i in 1 2 3; do curl -s -X POST localhost:4508/api/likes; done
{"likes":1}{"likes":2}{"likes":3}
$ curl -s localhost:4508/api/likes
{"likes":3}
$ curl -s -D - localhost:4508/api/views
HTTP/1.1 200 OK
x-nextjs-cache: HIT
cache-control: s-maxage=31536000

{"likes":0}
What’s happening
  1. At build time, Next.js tries to prerender every GET. /api/views reads a module variable synchronously: no request data, no clock, no I/O. From the build's point of view that's a constant, so it ran the handler once, got {"likes":0}, and stored it (○ /api/views).
  2. /api/likes is ƒ even though its GET is identical. The difference is the POST export. Next.js won't prerender a route file that exports POST, PUT, PATCH, DELETE or OPTIONS (the check is hasNonStaticMethods in next/dist/server/route-modules/app-route/module.js), so the whole file, GET included, runs per request. I built files whose GET returned a constant next to one other method: with POST, PUT, PATCH, DELETE or OPTIONS the route was ƒ; with HEAD it stayed ○. The same check applies without Cache Components: a force-static GET next to a POST was ƒ too.
  3. Each POST /api/likes runs live and increments the counter: 1 → 2 → 3. GET /api/likes runs live too and reads 3.
  4. GET /api/views never runs at request time. The server sends the stored build output with x-nextjs-cache: HIT, so the user sees 0 however many likes there are.
  5. The fix is to make the read honestly dynamic or cached with a lifetime: read from a real store (a database call is I/O, which stops prerendering), call await connection() from next/server to say "render at request time", or cache it with 'use cache' plus cacheTag and call revalidateTag after each write.
AdvancedNext.js 13–15: force-static and revalidate

How this worked before Cache Components

Most existing apps don't have cacheComponents: true. They use the older model, and its defaults changed in Next.js 15, which interviewers like to ask about:

  • Next.js 13.2–14: a GET handler was static by default. If it didn't read the request or use cookies()/headers(), it was run at build time and cached. Many people were surprised when their GET /api/time returned the build time.
  • Next.js 15 (and 16 without Cache Components): GET handlers are dynamic by default. You opt into caching with route segment config: export const dynamic = "force-static" or export const revalidate = 60.

Here are three handlers in an app with Cache Components turned off, which is how a Next.js 15 app behaves:

next.config.ts
import type { NextConfig } from "next";

// The pre-Cache Components model (what Next.js 15 apps use)
const nextConfig: NextConfig = {};

export default nextConfig;
app/api/time/route.ts
export async function GET() {
  return Response.json({ time: new Date().toISOString() });
}
app/api/time-static/route.ts
export const dynamic = "force-static";

export async function GET() {
  return Response.json({ time: new Date().toISOString() });
}
app/api/time-isr/route.ts
export const revalidate = 10;

export async function GET() {
  return Response.json({ time: new Date().toISOString() });
}
next build
Route (app)           Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/time
├ ○ /api/time-isr            10s      1y
└ ○ /api/time-static


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. All three bodies are identical: they read the clock. In this model the clock doesn't decide anything; the defaults and the config exports do.
  2. /api/time has no config, so it's dynamic by default (15+): two requests 0.3 s apart returned 18:05:05.162Z and 18:05:05.490Z, with no cache headers.
  3. /api/time-static says force-static: the build ran it once and every request got {"time":"2026-10-25T18:04:34.914Z"} with x-nextjs-cache: HIT and cache-control: s-maxage=31536000. The docs add that force-static makes cookies() and headers() return empty values instead of making the route dynamic.
  4. /api/time-isr has revalidate = 10: it's ISR for an API. Thirty seconds after the build, the first request got the build-time body labelled x-nextjs-cache: STALE with cache-control: s-maxage=10, stale-while-revalidate=31535990, and regeneration started in the background.
  5. These exports don't mix with Cache Components. Adding export const revalidate = 60 to a handler in a Cache Components app failed the build with Error: Route segment config "revalidate" is not compatible with nextConfig.cacheComponents. Please remove it. In the new model, 'use cache' and cacheLife replace them.
SituationNext.js 13.2–14Next.js 15, or 16 without Cache ComponentsNext.js 16 with Cache Components
GET that only returns constantsStaticDynamicStatic (prerendered)
GET that reads the clock or Math.random()Static (build-time value)DynamicDynamic
GET that reads request dataDynamicDynamicDynamic
Cache a GETDefault, or revalidate = ndynamic = "force-static" / revalidate = n'use cache' + cacheLife in a helper
POST, PUT, PATCH, DELETENever cachedNever cachedNever cached

#Streaming responses and webhooks

Added

A normal response is a parcel: the server builds the whole body, then hands it over. A streamed response is a conveyor belt: the server sends the headers and the first bytes straight away and keeps adding to the same response until it closes it. In a Route Handler you stream by returning new Response(stream) where stream is a web ReadableStream. Next.js sends the response with Transfer-Encoding: chunked and writes each chunk to the socket as you enqueue it. Use it for large exports, long-running reports, AI token streams and live feeds, anywhere the first bytes are useful before the last ones exist.

Server-Sent Events (SSE) are a streamed response with a tiny text format (id:, event:, data: lines, a blank line per message) that browsers parse for you with EventSource, including automatic reconnection. SSE is one-way, server to browser, over ordinary HTTP. That makes it a good fit for Route Handlers, which can't do WebSockets.

Webhooks are the opposite direction: someone else's server POSTs to your Route Handler when something happens (a payment succeeded, a CMS entry changed). The endpoint is public, so anyone can POST to it. What makes it safe is a signature: the provider computes an HMAC of the exact request body with a secret you share, and you recompute it. That's why webhook handlers must read the raw body, byte for byte, before parsing anything.

app/api/report/route.ts
const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function GET(request: Request) {
  const rows = Number(new URL(request.url).searchParams.get("rows") ?? 4);

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      controller.enqueue(encoder.encode("id,total\n")); // header row goes out at once
      for (let i = 1; i <= rows; i++) {
        await sleep(500); // pretend each row is a slow query
        controller.enqueue(encoder.encode(`${i},${i * 100}\n`));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/csv; charset=utf-8" },
  });
}
What’s happening
  1. The handler returns immediately with a Response wrapping a ReadableStream. The stream's start(controller) keeps running after the handler has returned; it's the producer on the belt.
  2. Streams carry bytes, so each string goes through TextEncoder into a Uint8Array. controller.enqueue(…) hands one chunk to Next.js, which writes it to the socket.
  3. The header row is enqueued before any waiting, so the client gets it at once. Then each row is enqueued after a 500 ms "query". controller.close() ends the response; forget it and the client waits for more until a timeout.
  4. Reading request.url makes this a dynamic route (ƒ /api/report in the table below), so it runs per request. A streamed GET that read nothing would be prerendered and buffered at build time, which is not what you want for a live export.
  5. A handler that built the whole CSV string first and returned it would send nothing until the last row was ready. The work is the same; only the time to the first byte changes.
AdvancedChunk timings measured with curl

I ran it with curl -N (no buffering in curl) and piped every line through a tiny Perl script that prints milliseconds since curl started:

Output
$ curl -sN -D - localhost:4508/api/report | perl stamp.pl
    9 ms  HTTP/1.1 200 OK
    9 ms  content-type: text/csv; charset=utf-8
    9 ms  Transfer-Encoding: chunked
    9 ms
    9 ms  id,total
  508 ms  1,100
 1010 ms  2,200
 1510 ms  3,300
 2011 ms  4,400

$ curl -s -o /dev/null -w 'first byte %{time_starttransfer}s, total %{time_total}s\n' 'localhost:4508/api/report?rows=4'
first byte 0.004253s, total 2.005315s
AdvancedReading the chunk timings

The headers and the first row arrived after 9 ms and the rest every half second; the first byte took 4 ms and the whole response 2 s. Each chunk was written when it was enqueued; nothing between the handler and the socket held them back.

AdvancedGotcha: proxies and CDNs that buffer
AdvancedServer-Sent Events with Last-Event-ID

Server-Sent Events

SSE is a streamed text/event-stream response in a fixed format. This endpoint sends a price every 500 ms, ends the connection after three, and uses event ids so a reconnecting browser can carry on where it stopped:

app/api/prices/route.ts
const encoder = new TextEncoder();

export async function GET(request: Request) {
  // A reconnecting EventSource sends the last id it saw
  const lastId = Number(request.headers.get("last-event-id") ?? 0);
  console.log(`[sse] connect, last-event-id=${lastId}`);
  let timer: ReturnType<typeof setInterval> | undefined;

  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      let id = lastId;
      const send = () => {
        id++;
        const data = JSON.stringify({ symbol: "ACME", price: 100 + id });
        controller.enqueue(encoder.encode(`id: ${id}\nevent: price\ndata: ${data}\n\n`));
      };

      controller.enqueue(encoder.encode("retry: 1000\n\n")); // reconnect delay for the browser
      send();
      timer = setInterval(() => {
        send();
        if (id >= lastId + 3) {
          clearInterval(timer);
          controller.close(); // end this connection after 3 events
        }
      }, 500);

      request.signal.addEventListener("abort", () => {
        clearInterval(timer);
        console.log(`[sse] client went away after id ${id}`);
      });
    },
    cancel() {
      clearInterval(timer);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no", // tell nginx not to buffer
    },
  });
}
What’s happening
  1. Each message is lines of field: value ending with a blank line (\n\n). id: is the event id, event: its name (the browser dispatches it as an event of that type), data: the payload. A message without the blank line isn't delivered.
  2. retry: 1000 tells the browser to wait 1 s before reconnecting when the stream ends. The first message goes out at once, then one every 500 ms; after three this connection is closed on purpose, which is how you'd cycle long-lived connections.
  3. When a browser reconnects it sends a Last-Event-ID header with the last id it got. The handler starts counting from there, so ids (and prices) continue instead of restarting at 1.
  4. request.signal is aborted when the client disconnects. Without the listener, the setInterval would keep firing into a stream nobody reads. cancel() is the stream's own hook for the same event; clearing the timer in both is belt and braces.
  5. The headers matter: text/event-stream is what EventSource requires; Cache-Control: no-cache, no-transform stops caches and compressing proxies from holding or rewriting the stream; X-Accel-Buffering: no turns off nginx buffering for this response.

Here's what curl showed (the same timing script), with a Last-Event-ID, and when curl gave up after 0.8 s:

Output
$ curl -sN -D - localhost:4508/api/prices | perl stamp.pl
   12 ms  HTTP/1.1 200 OK
   12 ms  cache-control: no-cache, no-transform
   12 ms  content-type: text/event-stream; charset=utf-8
   12 ms  x-accel-buffering: no
   12 ms  Transfer-Encoding: chunked
   12 ms
   12 ms  retry: 1000
   12 ms
   13 ms  id: 1
   13 ms  event: price
   13 ms  data: {"symbol":"ACME","price":101}
   13 ms
  511 ms  id: 2
  511 ms  event: price
  511 ms  data: {"symbol":"ACME","price":102}
  511 ms
 1012 ms  id: 3
 1012 ms  event: price
 1012 ms  data: {"symbol":"ACME","price":103}
 1012 ms

$ curl -sN -H "Last-Event-ID: 7" localhost:4508/api/prices | perl stamp.pl
    3 ms  retry: 1000
    3 ms  id: 8
    3 ms  data: {"symbol":"ACME","price":108}
  503 ms  id: 9
  503 ms  data: {"symbol":"ACME","price":109}
 1004 ms  id: 10
 1004 ms  data: {"symbol":"ACME","price":110}

$ curl -sN --max-time 0.8 localhost:4508/api/prices | perl stamp.pl
    3 ms  id: 1 …
  504 ms  id: 2 …

server log:
[sse] connect, last-event-id=0
[sse] connect, last-event-id=7
[sse] connect, last-event-id=0
[sse] client went away after id 2

(The second and third runs are shortened: the event: lines and blank lines are left out.) When curl hung up at 0.8 s, request.signal fired and the server logged it. The other two connections ended because the server closed them.

AdvancedEventSource reconnecting in the browser

The browser side: EventSource

app/ticker/page.tsx
import { Ticker } from "@/components/Ticker";

export default function TickerPage() {
  return (
    <main>
      <h1>ACME live price</h1>
      <Ticker />
    </main>
  );
}
components/Ticker.tsx
"use client";

import { useEffect, useState } from "react";

export function Ticker() {
  const [events, setEvents] = useState<string[]>([]);

  useEffect(() => {
    const source = new EventSource("/api/prices");
    source.addEventListener("price", (e) => {
      const { price } = JSON.parse((e as MessageEvent).data);
      setEvents((prev) => [...prev, `#${(e as MessageEvent).lastEventId} ${price}`]);
    });
    return () => source.close(); // closing stops the browser reconnecting
  }, []);

  return <ol id="events">{events.map((e) => <li key={e}>{e}</li>)}</ol>;
}
What’s happening
  1. EventSource only exists in the browser, so it lives in a Client Component and is opened in useEffect. The page itself is static (○ /ticker); only the stream is live.
  2. addEventListener("price", …) matches the event: price lines. Messages without an event: field would arrive as message events instead. e.lastEventId is the id: of that message.
  3. I opened /ticker in headless Chrome and read the list. After 2 s it held #1 101, #2 102, #3 103; after 5 s it held #1 101 through #8 108, with no gaps.
  4. The server log explains it: [sse] connect, last-event-id=0, then last-event-id=3, then last-event-id=6. Each time the server closed a connection, the browser waited the 1 s retry, reconnected on its own and sent the last id it had seen. When I closed the browser, the log said [sse] client went away after id 8.
  5. The cleanup source.close() matters: an EventSource that isn't closed keeps reconnecting forever, even after the component unmounts.
AdvancedVerifying a signed webhook

Receiving a webhook safely

Here's a payments-style webhook. The provider sends x-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">, the scheme Stripe and others use. The secret is a test value in .env:

.env
WEBHOOK_SECRET=whsec_test_3f9a1c7e5b2d4a60
app/api/webhooks/orders/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;
const seen = new Set<string>(); // use a database table in real code

export async function POST(request: Request) {
  // 1. Header looks like: x-signature: t=1793037600,v1=5d41…
  const header = request.headers.get("x-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) {
    return Response.json({ error: "missing signature" }, { status: 400 });
  }
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return Response.json({ error: "timestamp outside tolerance" }, { status: 400 });
  }

  // 2. Sign the exact bytes that arrived: read the raw text, never re-serialised JSON
  const raw = await request.text();
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(`${timestamp}.${raw}`)
    .digest();
  const received = Buffer.from(parts.v1, "hex");
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
    return Response.json({ error: "bad signature" }, { status: 401 });
  }

  // 3. Only now trust the payload, and make handling idempotent
  const event = JSON.parse(raw) as { id: string; type: string };
  if (seen.has(event.id)) return Response.json({ received: true, duplicate: true });
  seen.add(event.id);
  console.log(`[webhook] ${event.type} ${event.id}`);
  return Response.json({ received: true });
}
Provider: HMAC-SHA256(secret, "t.body") → POST with x-signature: t=…,v1=… 1. Parse header t=…, v1=… 2. Fresh? |now − t| ≤ 300 s 3. Recompute HMAC(t.rawBody) 4. Compare timingSafeEqual 5. New event id? dedupe retries 400 missing 400 replayed raw text, not parsed JSON 401 bad sig 200 process seen: 200, skip
Each check either stops the request with its own status or passes it on. Only step 5 trusts the payload.
What’s happening
  1. The cheap checks come first. A missing or malformed header is a 400 before any crypto runs. The timestamp check rejects anything signed more than 5 minutes ago (or ahead), so a captured request can't be replayed later: the signature covers t, so an attacker can't just change it.
  2. await request.text() reads the body exactly as it arrived. The HMAC is computed over `${timestamp}.${raw}`, the same string the provider signed. A body can only be read once (a second read throws TypeError: Body is unusable: Body has already been read), so read it as text and JSON.parse that string afterwards.
  3. timingSafeEqual compares the two digests in constant time. A plain === on hex strings returns as soon as a character differs, which leaks, by timing, how much of a guessed signature was right. timingSafeEqual throws if the lengths differ (RangeError: Input buffers must have the same byte length), hence the length check before it.
  4. Only after the signature passes is the payload trusted and parsed. Providers deliver at least once and retry on timeouts or non-2xx answers, so the handler records the event id and answers a repeat with 200 without doing the work twice. A Set is fine for a demo; real code uses a unique index in the database, checked and written in one transaction with the side effect.
  5. Next.js needs no configuration for any of this. The Pages Router's API Routes parsed the body for you, so webhook code there had to turn the parser off (export const config = { api: { bodyParser: false } }) to get raw bytes. Route Handlers never parse the body; you choose .text(), .json(), .formData() or .arrayBuffer().

I signed requests in the shell with openssl (the same t.body scheme) and sent them with curl:

Shell
SECRET=whsec_test_3f9a1c7e5b2d4a60
BODY='{"id":"evt_1001","type":"order.paid","data":{"orderId":"ord_42","amount":1999}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s -X POST localhost:4508/api/webhooks/orders \
  -H "x-signature: t=$T,v1=$SIG" -H 'content-type: application/json' --data-raw "$BODY"
Output
# 1. correctly signed
{"received":true}  200
# 2. the same delivery again (provider retry)
{"received":true,"duplicate":true}  200
# 3. body tampered: amount 1999 -> 1, same signature
{"error":"bad signature"}  401
# 4. same JSON, re-serialised with spaces
{"error":"bad signature"}  401
# 5. a captured delivery replayed 10 minutes later
{"error":"timestamp outside tolerance"}  400
# 6. no signature header
{"error":"missing signature"}  400
# 7. signed with the wrong secret
{"error":"bad signature"}  401

server log:
[webhook] order.paid evt_1001

Case 4 is the interesting one. The JSON means the same thing, but the bytes differ (spaces after colons and commas), so the HMAC differs. The signature protects bytes, not meaning, which is why the next example breaks.

Parse first, verify after

A teammate "simplifies" the handler: parse the JSON, then rebuild the string to check the signature. The provider is the same. Which deliveries does it accept?

app/api/webhooks/orders-v2/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const header = request.headers.get("x-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));

  const event = await request.json(); // parse first...
  const signed = `${parts.t}.${JSON.stringify(event)}`; // ...then rebuild the string to check
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(signed).digest();
  const received = Buffer.from(parts.v1 ?? "", "hex");

  if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
    return Response.json({ error: "bad signature" }, { status: 401 });
  }
  return Response.json({ received: true });
}
Show answer

It accepts a body only if the provider happened to send it in exactly the shape JSON.stringify produces. A compact body passed; the same kind of event sent pretty-printed, correctly signed, was rejected, and the raw-text handler accepted that same pretty-printed delivery:

Output
# to /api/webhooks/orders-v2 (my script prints newlines in the body as ~)
body: {"id":"evt_2001","type":"order.paid"}
{"received":true}  200
body: {~  "id": "evt_2002",~  "type": "order.paid"~}
{"error":"bad signature"}  401
--- same pretty body to the raw-text route:
{"received":true}  200
What’s happening
  1. request.json() turns the bytes into an object and throws away the formatting: whitespace, newlines and the exact spelling of escaped characters.
  2. JSON.stringify(event) writes a new string with no whitespace. For the compact body that new string happens to equal the original bytes, so the HMAC matches and the test passes, which is why this bug survives a quick local test.
  3. For the pretty-printed body the rebuilt string has no newlines or indentation, so the HMAC is computed over different bytes and the delivery gets a 401. Providers are free to format their JSON however they like, and pretty-printed payloads are common, so in production every delivery could fail; the provider retries and may eventually disable the endpoint.
  4. Key order, number formatting (1.0 vs 1) and Unicode escapes (é vs é) break it the same way. The only reliable input is the raw body: await request.text() (or arrayBuffer() for binary-safe handling), verify, then JSON.parse.
  5. It also never checks the timestamp, so a captured compact delivery could be replayed forever.
AdvancedTip: provider SDK verify functions
AdvancedGotcha: slow webhook handlers get retried
AdvancedRoute table for the streaming app

The route table for the streaming app:

next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/prices
├ ƒ /api/report
├ ƒ /api/webhooks/orders
├ ƒ /api/webhooks/orders-v2
└ ○ /ticker


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

#proxy.ts (formerly middleware)

Added

proxy.ts is one function that runs before routing, for every request its matcher selects. It sees the incoming request (URL, method, headers, cookies) and decides what happens next: let it through unchanged, let it through with extra headers or cookies, rewrite it to a different route while the browser keeps the URL, redirect the browser elsewhere, or answer it directly with a Response. It's the place for cheap, request-shaped decisions that apply across many routes: "no session cookie, go to /login", "this visitor is in experiment B", "French browser, go to /fr", "add a request id".

The mental model is a doorman at the building entrance. Every visitor passes them before reaching any room. A good doorman glances at a ticket and points: through you go, use the side door, you're in the wrong building, or not tonight. A doorman who stops each visitor to phone the head office (a slow database query) makes the whole queue wait. So proxy logic should be fast and based on what's in the request itself, and real security checks still happen inside the rooms, next to the data.

Until Next.js 16 this file was called middleware.ts and the function middleware. Next.js 16 renamed it to proxy.ts; the docs give two reasons. "Middleware" made people expect Express middleware (a chain of functions around every handler), which it never was. And the team wants it used as a last resort for network-edge work, not as the place for app logic. With the rename came a runtime change: proxy.ts always runs on the Node.js runtime and you can't configure it, while middleware.ts ran on the Edge runtime by default.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log(`[proxy] ${request.method} ${request.nextUrl.pathname}`);

  const response = NextResponse.next(); // carry on to the route
  response.headers.set("x-proxy", "hit");
  response.headers.set("x-proxy-runtime", `node ${process.versions.node}`);
  return response;
}

export const config = {
  // Everything except API routes, build assets, image optimisation and the favicon
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
What’s happening
  1. The file sits at the project root, or in src/ next to app/ as here. There's one per project; to organise logic, import helpers into it. It exports a function named proxy (a default export works too) and an optional config.
  2. NextResponse.next() means "continue to whatever route would have handled this". Headers set on that response are added to whatever the route returns, so the page's HTML arrives with x-proxy: hit attached.
  3. process.versions.node works because proxy runs on Node.js: the response said x-proxy-runtime: node 26.7.0. Node built-ins such as node:crypto are available, unlike the old Edge default.
  4. The matcher is a path pattern (path-to-regexp syntax with regular expressions allowed). This one uses a negative lookahead to skip anything starting with api, _next/static, _next/image or favicon.ico. It's read at build time, so it must be a constant; a variable is ignored.
  5. Adding a proxy didn't make any page dynamic. All the pages are still ○ in the route table: proxy runs in front of the stored HTML, not inside the render.
AdvancedThe pages behind the proxy

The routes behind it are deliberately tiny: a home page that links to /about, the about page, and a health check under /api.

app/page.tsx
import Link from "next/link";

export default function Home() {
  return (
    <main>
      <h1>Home</h1>
      <Link href="/about">About</Link>
    </main>
  );
}
app/about/page.tsx
export default function About() {
  return <h1>About</h1>;
}
app/api/health/route.ts
export function GET() {
  return Response.json({ ok: true });
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ○ /api/health


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
AdvancedHow the build lists the proxy

The build lists the proxy separately as ƒ Proxy (Middleware): it runs on every matching request, whatever the route.

AdvancedWhich requests run the proxy, measured

Which requests actually run it

I requested a range of URLs from the running app and checked for the x-proxy header (and the log line):

Output
GET /                                      200  x-proxy=hit
GET /about                                 200  x-proxy=hit
GET /api/health                            200  x-proxy=
GET /next.svg                              200  x-proxy=hit
GET /favicon.ico                           200  x-proxy=
GET /_next/static/chunks/3eubc84y_zav4.css 200  x-proxy=
GET /does-not-exist                        404  x-proxy=hit
GET /about  (with header RSC: 1)               x-proxy: hit  x-proxy-runtime: node 26.7.0
What’s happening
  1. Pages (/, /about) match the pattern, so the proxy ran and added its header.
  2. /api/health, /favicon.ico and the CSS chunk under /_next/static/ are excluded by the lookahead: no header, no log line. Without that exclusion, an auth redirect in proxy would also redirect your CSS and JavaScript, and the login page would load unstyled.
  3. /next.svg is a file from public/, and the matcher didn't exclude it, so the proxy ran for a static image. The docs say it plainly: without a matcher, proxy runs on every request, including public/ assets. Add file extensions to the exclusion (for example .*\\.(?:svg|png|jpg)$) if your proxy does anything expensive.
  4. /does-not-exist ran the proxy and then got a 404. Proxy runs before routing, so it can't know whether a route exists.
  5. An RSC request (what client-side navigation sends) for /about ran the proxy too, with the same pathname /about. Next.js strips its internal router headers (rsc, next-router-state-tree, …) from what your proxy sees, so the page request and its RSC request are handled the same way.

Then I loaded / in headless Chrome and clicked the link to /about. The proxy log showed GET / and then two GET /about lines before I clicked, and nothing after the click: <Link> prefetched /about (with partial prefetching, as two requests), the prefetches went through the proxy, and the click was served from what had been prefetched. So prefetches run your proxy, and a proxy that redirects or rewrites is also deciding what gets prefetched.

Every request, in this order (proxy only where its matcher matches) Request GET /dashboard 1. next.config headers() 2. next.config redirects() 3. proxy.ts if matcher matches redirect() 307/308 return a Response rewrite(url) next() straight back to the browser into routing 4. beforeFiles rewrites 5. Filesystem public, _next, app/ 6. afterFiles rewrites 7. Dynamic [slug] routes 8. fallback rewrites
The order from the proxy docs. A next.config redirect wins before proxy even runs; a proxy redirect or Response skips routing entirely, and a rewrite continues through routing with the new path.
Advancedmiddleware.ts in 16: deprecated, still Edge

The rename, and what the old file still does

middleware.ts still works in Next.js 16, with a warning. I built an app with this file and one page:

middleware.ts
import { NextResponse, type NextRequest } from "next/server";

declare const EdgeRuntime: string | undefined;

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  response.headers.set("x-middleware-runtime", typeof EdgeRuntime === "string" ? "edge" : "nodejs");
  return response;
}
Output
$ next build
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.

  To migrate automatically, run:
  npx @next/codemod@canary middleware-to-proxy .

  Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
next build
Route (app)
┌ ○ /
└ ○ /_not-found


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
What’s happening
  1. The build printed the deprecation warning and then built normally; the route table lists it as ƒ Proxy (Middleware) just like a proxy.ts.
  2. EdgeRuntime is a global string that exists only in the Edge runtime, so the header reports where the function ran. curl -D - localhost:4508/ returned x-middleware-runtime: edge. A middleware.ts still runs on Edge by default in 16.4.3; renaming the file to proxy.ts moves the code to Node.js.
  3. That's why the upgrade guide warns against a blind rename: Edge-specific code keeps working on Node, but code that relied on Edge limits or deployment placement behaves differently, and the guide says that if you need Edge you should keep middleware.ts for now.
  4. You can't have both. With middleware.ts and proxy.ts side by side the build failed: Error: Both middleware file "./src/src/middleware.ts" and proxy file "./src/src/proxy.ts" are detected. Please use "./src/src/proxy.ts" only. (the doubled src/src is in the real message).
  5. You can't opt proxy.ts back into Edge either. Adding export const runtime = "edge" to it failed the build with Error: Route segment config is not allowed in Proxy file at "./src/proxy.ts". Proxy always runs on Node.js runtime. (The same export added to the first proxy in this topic, which reads process.versions, failed earlier, in Turbopack, with A Node.js API is used (process.versions at line: 8) which is not supported in the Edge Runtime.)

The app's only page was a one-line app/page.tsx:

app/page.tsx
export default function Page() {
  return <h1>Home</h1>;
}

The codemod does the rename for you: it renames the file and the exported function, and the config flags that had "middleware" in their name (skipMiddlewareUrlNormalize became skipProxyUrlNormalize). Libraries still often say "middleware" in their docs and export names (export default createMiddleware() in a proxy.ts is normal).

middleware.ts (Next.js 12–15, deprecated in 16)proxy.ts (Next.js 16)
File and exportmiddleware.ts, export function middlewareproxy.ts, export function proxy (or default)
RuntimeEdge by default; Node.js experimental in 15.2, stable in 15.5Always Node.js, not configurable
Node APIs (fs, node:crypto, most npm packages)Only with the Node.js runtimeYes
Matcher, NextResponse.next/rewrite/redirect, cookiesSameSame
Typical first versions12 beta, 12.2 stable, 13 can set request headers and return responses16.0 rename
AdvancedWhat not to do in proxy
  • Don't make it your only authorisation. In March 2025 (CVE-2025-29927) it turned out that a request carrying an internal x-middleware-subrequest header could make Next.js skip middleware entirely, so apps whose only auth check was in middleware were open. Patched versions ignore the header (I tested 16.4.3 in the next topic), but the lesson stands: proxy is an optimistic check for fast redirects, and the page, the Route Handler, the Server Action and the data layer verify the session again. See Authorisation close to the data.
  • Don't fetch slow data. It runs before every matched request, including prefetches. The docs say it's not for slow data fetching or full session management, and fetch cache options (cache, next.revalidate, next.tags) have no effect there.
  • Don't rely on module globals. The docs warn that proxy may be deployed separately from your render code (to a CDN, in optimised setups), so a module-level Map isn't shared with your pages. Pass information on with headers, cookies, rewrites or the URL.
  • Don't forget Server Actions. They're POST requests to the page's own URL, not separate routes. If the matcher excludes a path, actions posted to that path skip the proxy too.
  • Don't send large headers, which some servers reject with 431. And note that when a proxy exists, Next.js buffers request bodies in memory so that both the proxy and the route can read them, up to 10 MB by default (the experimental proxyClientMaxBodySize option changes the limit).
  • Prefer config when the rule is static. A fixed redirect belongs in redirects() in next.config.ts, a fixed header in headers(). They're simpler and run before proxy. Use proxy when the decision needs the request: a cookie, a header, a geo or A/B value.
Which requests run this proxy?

With the matcher ["/((?!api|_next/static|_next/image|favicon.ico).*)"], which of these run the proxy: /, /api/health, /next.svg (a file in public/), /_next/static/chunks/main.js, /blog/missing-post (no such page), and a <Link> prefetch of /about?

Show answer

/, /next.svg, /blog/missing-post and the prefetch run it. /api/health and the _next/static chunk don't.

What’s happening
  1. The pattern is / followed by anything that does not start with api, _next/static, _next/image or favicon.ico. / matches with an empty rest.
  2. /api/health starts with api, so the lookahead fails and the proxy is skipped. Note this also skips /apiary and /api-docs; write api/ if you mean only the folder.
  3. /next.svg doesn't start with any excluded prefix, so it matches. In my test the public/ file /next.svg got x-proxy: hit. Static files are only excluded if you exclude them.
  4. /_next/static/… is excluded by name. If it weren't, every JavaScript and CSS request would run your proxy.
  5. /blog/missing-post matches: matching is about the URL, and proxy runs before Next.js looks for a route, so it runs and then the request 404s. The <Link> prefetch of /about is an ordinary request to /about as far as the matcher is concerned, and in my test both prefetch requests ran the proxy.

#Redirects, rewrites and headers in proxy

Added

Proxy has four moves, and the difference between the first two is the thing to get right.

A redirect tells the browser to go somewhere else: the response is a 307 or 308 with a Location header, the browser makes a second request, and the address bar changes. A rewrite happens entirely on the server: Next.js serves a different route for this request, the browser never knows, and the URL stays the same. Think of post: a redirect is a letter returned with "moved, send it to this address", so the sender posts it again; a rewrite is the mailroom quietly delivering it to another desk. Use a redirect when the user should see and bookmark the new URL (login pages, canonical locales, moved pages). Use a rewrite when one public URL should show different content (A/B tests, multi-tenant hostnames, serving /pricing from /pricing/b).

The other two moves pass information along. Request headers set with NextResponse.next({ request: { headers } }) travel upstream to your pages and Route Handlers (read them with headers()), and the client never sees them. Response headers and cookies set on the returned response go downstream to the browser. Mixing those two up is a classic leak.

This app does all four: an auth gate, a sticky A/B test, locale redirects and a request id.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";

const LOCALES = ["en", "fr"];

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  console.log(`[proxy] ${pathname}`);

  // 1. Auth gate: an optimistic check, the page still verifies the session
  if (pathname.startsWith("/dashboard") && !request.cookies.has("session")) {
    const login = new URL("/login", request.url);
    login.searchParams.set("from", pathname);
    return NextResponse.redirect(login); // 307
  }

  // 2. A/B test: one URL, two pages, sticky per visitor
  if (pathname === "/pricing") {
    const bucket = request.cookies.get("bucket")?.value ?? (Math.random() < 0.5 ? "a" : "b");
    const response = NextResponse.rewrite(new URL(`/pricing/${bucket}`, request.url));
    response.cookies.set("bucket", bucket, { path: "/", maxAge: 60 * 60 * 24 * 30 });
    return response;
  }

  // 3. i18n: pick a locale for URLs that don't have one
  const hasLocale = LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`));
  if (!hasLocale && (pathname === "/" || pathname === "/about")) {
    const lang = request.headers.get("accept-language")?.toLowerCase().startsWith("fr") ? "fr" : "en";
    return NextResponse.redirect(new URL(`/${lang}${pathname === "/" ? "" : pathname}`, request.url));
  }

  // 4. Everything else: tag the request with an id, upstream and downstream
  const requestId = crypto.randomUUID();
  const upstream = new Headers(request.headers);
  upstream.set("x-request-id", requestId); // overwrites anything the client sent
  const response = NextResponse.next({ request: { headers: upstream } });
  response.headers.set("x-request-id", requestId);
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
What’s happening
  1. Auth gate. No session cookie on a /dashboard URL → NextResponse.redirect(login), a 307 to /login?from=%2Fdashboard. Proxy only checks that the cookie exists: decrypting and validating a session belongs next to the data. from lets the login page send the user back afterwards (validate it there, to avoid an open redirect).
  2. A/B test. /pricing is rewritten to /pricing/a or /pricing/b. A returning visitor's bucket cookie decides; a new visitor gets a coin toss, and the cookie set on the rewrite response keeps them in that bucket for 30 days. Math.random() is fine here: proxy runs per request and isn't prerendered.
  3. Locale. / and /about without a locale prefix are redirected to /en… or /fr… from Accept-Language. The App Router has no built-in i18n routing (the Pages Router's i18n config doesn't apply to app/), so this is the usual place for it. Real code would parse the q weights or use a negotiation library.
  4. Request id. new Headers(request.headers) copies the incoming headers; set replaces any x-request-id the client sent, so a client can't choose its own id. NextResponse.next({ request: { headers: upstream } }) forwards the modified headers to the route, and response.headers.set also sends the id back to the browser.
  5. Every branch returns. Returning nothing (undefined) is the same as NextResponse.next(), but explicit returns make the decision visible.
AdvancedConfig redirect and the routed pages

The rest of the app is small. The config adds one plain redirect (it comes up in the quiz below), the dashboard reads the forwarded header inside <Suspense>, and the other pages are one-liners or a dictionary lookup:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  async redirects() {
    return [{ source: "/old-pricing", destination: "/pricing", permanent: true }];
  },
};

export default nextConfig;
AdvancedDashboard reading the forwarded header
app/dashboard/page.tsx
import { Suspense } from "react";
import { headers } from "next/headers";

async function RequestId() {
  const id = (await headers()).get("x-request-id");
  return <p id="rid">Request id seen by the page: {id}</p>;
}

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>…</p>}>
        <RequestId />
      </Suspense>
    </main>
  );
}
app/login/page.tsx
export default function Login() {
  return <h1>Log in</h1>;
}
app/pricing/a/page.tsx
export default function PricingA() {
  return <h1>Pricing (A): $10 a month</h1>;
}
app/pricing/b/page.tsx
export default function PricingB() {
  return <h1>Pricing (B): $100 a year</h1>;
}
app/[lang]/page.tsx
import { notFound } from "next/navigation";

const hello: Record<string, string> = { en: "Hello", fr: "Bonjour" };

export function generateStaticParams() {
  return [{ lang: "en" }, { lang: "fr" }];
}

export default async function Home({ params }: PageProps<"/[lang]">) {
  const { lang } = await params;
  if (!hello[lang]) notFound();
  return <h1>{hello[lang]}</h1>;
}
AdvancedLocalised about page
app/[lang]/about/page.tsx
import { notFound } from "next/navigation";

const about: Record<string, string> = { en: "About us", fr: "À propos" };

export function generateStaticParams() {
  return [{ lang: "en" }, { lang: "fr" }];
}

export default async function About({ params }: PageProps<"/[lang]/about">) {
  const { lang } = await params;
  if (!about[lang]) notFound();
  return <h1>{about[lang]}</h1>;
}
AdvancedRoute table: pages stay static behind proxy
next build
Route (app)
┌ ○ /_not-found
├   /[lang]
│ ├ ◐ /[lang]
│ ├ ○ /en
│ └ ○ /fr
├   /[lang]/about
│ ├ ◐ /[lang]/about
│ ├ ○ /en/about
│ └ ○ /fr/about
├ ◐ /dashboard
├ ○ /login
├ ○ /pricing/a
└ ○ /pricing/b


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedPersonalising static pages from proxy

Look at what the proxy didn't cost. Both pricing variants and the locale pages are fully static (○); only /dashboard is partially prerendered (◐), because it reads headers() inside <Suspense>. The personalisation (which variant, which language) happens in proxy, in front of static HTML. That's the main reason to do A/B tests and locale routing there rather than reading cookies in the page.

This is what the running app returned (curl -s -D -, showing the interesting headers and the page's <h1>):

Output
$ curl -s -D - localhost:4508/dashboard
HTTP/1.1 307 Temporary Redirect
location: /login?from=%2Fdashboard

$ curl -s -D - -b session=abc -H 'x-request-id: forged-by-client' localhost:4508/dashboard
HTTP/1.1 200 OK
x-request-id: feefb84f-0939-4f85-b379-f142a0b1b9eb
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
<h1>Dashboard</h1>  Request id seen by the page: feefb84f-0939-4f85-b379-f142a0b1b9eb

$ curl -s -D - localhost:4508/pricing                 (new visitor)
HTTP/1.1 200 OK
set-cookie: bucket=a; Path=/; Expires=Tue, 24 Nov 2026 18:10:16 GMT; Max-Age=2592000
<h1>Pricing (A): $10 a month</h1>

$ curl -s -D - -b bucket=b localhost:4508/pricing
HTTP/1.1 200 OK
set-cookie: bucket=b; Path=/; Expires=Tue, 24 Nov 2026 18:20:48 GMT; Max-Age=2592000
x-middleware-rewrite: /pricing/b
x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000
<h1>Pricing (B): $100 a year</h1>

$ curl -s -D - -H 'Accept-Language: fr-CA,fr;q=0.9' localhost:4508/about
HTTP/1.1 307 Temporary Redirect
location: /fr/about

$ curl -s -D - localhost:4508/
HTTP/1.1 307 Temporary Redirect
location: /en

$ curl -s localhost:4508/fr/about
<h1>À propos</h1>
What’s happening
  1. Without a cookie, /dashboard never reached the page: the proxy answered with the 307 itself. In headless Chrome the same URL ended on /login?from=%2Fdashboard showing "Log in".
  2. With a cookie, the page rendered, and the id in the response header and the id the page read with headers() are the same value, so one request id ties the browser, the proxy and the server logs together. The client's x-request-id: forged-by-client was replaced. If the proxy had only added a header without overwriting it, a client could inject values your pages trust (x-user-id is the dangerous version of this).
  3. /pricing showed variant A to a new visitor (the coin toss) and set bucket=a; with bucket=b it showed variant B. In headless Chrome the address bar stayed /pricing across reloads, with document.cookie = bucket=b. The response carried x-middleware-rewrite: /pricing/b, so the internal path is visible to anyone who looks; don't put secrets in rewrite paths.
  4. The locale redirects are 307s to /fr/about and /en, and /fr/about is the static French page.
  5. All proxy redirects here were 307 Temporary: NextResponse.redirect(url) defaults to 307, and NextResponse.redirect(url, 308) makes it permanent. Temporary is right for redirects that depend on a cookie or a header, since browsers and CDNs may cache permanent ones.
AdvancedGotcha: CDN caching of rewritten pages
AdvancedGotcha: unknown locales return 200
AdvancedRequest headers vs response headers

The direction is set by where you put the headers:

CodeWho sees the header
NextResponse.next({ request: { headers } })Your pages, layouts, Route Handlers and Server Actions (via headers()); not the browser
response.headers.set(name, value) on the returned responseThe browser
NextResponse.next({ headers })The browser. The docs say to avoid it: it can override framework headers like Content-Type and break Server Actions or streaming
NextResponse.rewrite(url, { request: { headers } })The rewrite target

The docs also advise against forwarding all incoming headers blindly. Copy the request headers and remove or overwrite the ones you don't trust (authorization, cookie, custom x-*), and never write a secret into a response header: it goes to the client.

AdvancedTesting the CVE-2025-29927 bypass header

Testing the 2025 bypass header

CVE-2025-29927 worked by sending the header Next.js used internally to mark a middleware sub-request, with the middleware's name repeated, so Next.js thought it had already run middleware and skipped it. I sent the patterns that worked against vulnerable versions to /dashboard on 16.4.3, without a session cookie:

Output
$ curl -s -D - -o /dev/null -H 'x-middleware-subrequest: proxy:proxy:proxy:proxy:proxy' localhost:4508/dashboard
HTTP/1.1 307 Temporary Redirect
location: /login?from=%2Fdashboard
$ curl … -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' …
HTTP/1.1 307 Temporary Redirect
location: /login?from=%2Fdashboard
$ curl … -H 'x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware' …
HTTP/1.1 307 Temporary Redirect
location: /login?from=%2Fdashboard

All three were still redirected and the proxy logged each one, so this version doesn't skip proxy for that header. The fixed versions were 12.3.5, 13.5.9, 14.2.25 and 15.2.3 (advisory GHSA-f82v-jwr5-mffw, published 21 March 2025; checked against the advisory and Vercel's postmortem in October 2026). It hit self-hosted apps (next start, output: 'standalone'); the postmortem says deployments on Vercel, Netlify and Cloudflare Workers weren't affected. The workaround for apps that couldn't upgrade was to strip x-middleware-subrequest at the load balancer or WAF. The design lesson is the bigger one: if your only authorisation is in proxy, one framework bug exposes everything, so verify in the page, Route Handler, Server Action and data layer too. See the Security checklist for a Next.js app.

Does the proxy see /old-pricing?

next.config.ts redirects /old-pricing to /pricing (permanent), and the proxy logs every pathname it sees and rewrites /pricing. A visitor opens /old-pricing. What does the proxy log, and what does the visitor end up seeing?

Show answer

The proxy logs nothing for /old-pricing. Next.js answers it with the config redirect before the proxy runs, the browser follows it to /pricing, and only that second request goes through the proxy, which rewrites it to the visitor's variant.

Output
$ curl -s -D - -o /dev/null localhost:4508/old-pricing
HTTP/1.1 308 Permanent Redirect
location: /pricing
What’s happening
  1. The order is: next.config headers, then next.config redirects, then proxy, then rewrites and routes (the diagram in the previous topic). A config redirect that matches ends the request there.
  2. My server log had no [proxy] /old-pricing line for that request, although the matcher would have matched the path.
  3. permanent: true produces 308 (not 301): 308 keeps the method and body, like 307 does for temporary redirects. Next.js uses 307/308 so a redirected POST stays a POST.
  4. The browser then requests /pricing, the proxy runs ([proxy] /pricing), reads or sets the bucket cookie and rewrites. The visitor sees /pricing in the address bar and their variant.
  5. Practical upshot: a config redirect can't be overridden by proxy logic, and moving a rule between the two changes behaviour. Static, unconditional redirects belong in config; anything that depends on the request belongs in proxy.

#after(): work after the response

Added

Plenty of server work doesn't change what the user sees: writing an analytics event, an audit log line, warming a cache, sending a notification to a queue. If a handler awaits it before returning, the user waits for it. If the handler fires it off without awaiting (void track()), the user doesn't wait, but nothing guarantees the work finishes: on serverless platforms the instance can be frozen once the response is sent, and even a long-running server may be shutting down. after(callback) from next/server is the supported middle path: schedule the callback to run once the response has finished, and keep the request alive until it's done.

The mental model is a waiter who brings your food first and wipes the next table afterwards. The cleaning still happens, and the restaurant doesn't close until it's done, but you don't watch it before you eat. On platforms with a waitUntil primitive (Vercel and other serverless hosts), after() hands the promise to it; with next start, the server tracks pending callbacks itself and waits for them when it shuts down.

after() works in Route Handlers, Server Actions, Server Components (including generateMetadata) and proxy. It's been stable since Next.js 15.1 (it was unstable_after in 15.0). Calling it doesn't make a route dynamic.

app/api/checkout/route.ts
import { after } from "next/server";
import { track } from "@/lib/analytics";

export async function POST() {
  const startedAt = Date.now();
  // ...charge the card, save the order...

  after(() => track("checkout", startedAt)); // runs once the response has been sent

  console.log(`[checkout] responding after ${Date.now() - startedAt}ms`);
  return Response.json({ ok: true });
}
What’s happening
  1. after(() => track(...)) registers the callback and returns at once; nothing runs yet. If the callback returns a promise (as here), Next.js waits for that promise too.
  2. The handler logs and returns the response. The server log said [checkout] responding after 0ms, and curl measured the whole request at 0.027081s.
  3. Once the response is finished, Next.js calls the callback. track() (below) takes 1.5 s, and it runs while the user already has their answer: [track] checkout stored 1506ms after the request arrived.
  4. startedAt reaches the callback through the closure. after() callbacks are ordinary closures over whatever the handler had in scope when it scheduled them.
  5. Compare the blocking version further down, which does the same work with await before returning: the user waited 1.5 s for the same {"ok":true}.
AdvancedThe slow track() helper

track() stands in for a slow call to an analytics service:

lib/analytics.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Stands in for a slow call to an analytics or logging service
export async function track(event: string, startedAt: number) {
  await sleep(1500);
  console.log(`[track] ${event} stored ${Date.now() - startedAt}ms after the request arrived`);
}
AdvancedComparison: awaiting the work instead

To compare, the same work with await before the response:

app/api/checkout-blocking/route.ts
import { track } from "@/lib/analytics";

export async function POST() {
  const startedAt = Date.now();
  await track("checkout-blocking", startedAt); // the user waits for analytics
  return Response.json({ ok: true });
}
What’s happening
  1. await track(...) holds the handler for 1.5 s, so the response can't be sent until analytics has finished.
  2. curl waited 1.503726s for {"ok":true}, against 0.027081s with after(). Same work, but the user pays for it.
  3. The log line appears either way: blocking work is reliable but slow, and after() gives the same reliability without the wait (the next section shows that a floating promise doesn't).
AdvancedResponse times: after() vs await
Output
$ curl -s -X POST localhost:4508/api/checkout -w '  %{time_total}s\n'
{"ok":true}  0.027081s
$ curl -s -X POST localhost:4508/api/checkout-blocking -w '  %{time_total}s\n'
{"ok":true}  1.503726s

server log:
[checkout] responding after 0ms
[track] checkout stored 1506ms after the request arrived
[track] checkout-blocking stored 1500ms after the request arrived
AdvancedWhy not just skip the await?

Why not just skip the await?

A fire-and-forget promise also returns quickly. The difference shows when the process stops. I sent a request and a SIGTERM to the next start process straight after the response, once with after() and once with a floating promise:

app/api/checkout-floating/route.ts
import { track } from "@/lib/analytics";

export async function POST() {
  const startedAt = Date.now();
  void track("checkout-floating", startedAt); // fire and forget: nobody waits for this promise
  return Response.json({ ok: true });
}
Output
# /api/checkout (after), SIGTERM right after the response
{"ok":true}
  response at 10ms
  SIGTERM sent at 10ms
  server exited at 1553ms
server log: [track] checkout stored 1501ms after the request arrived

# /api/checkout-floating (void promise), same sequence
{"ok":true}
  response at 17ms
  SIGTERM sent at 17ms
  server exited at 72ms
server log: (nothing: track never finished)
What’s happening
  1. With after(), the server received SIGTERM 10 ms in, but didn't exit until 1553 ms: it waited for the pending callback, which logged at 1501 ms. The self-hosting docs describe this: on SIGINT/SIGTERM, next start finishes in-flight requests and pending after() callbacks before exiting.
  2. With the floating promise, the server had nothing registered to wait for, so it exited 55 ms after the signal and the analytics call was lost. On a deploy, every request in flight at shutdown would lose its work the same way.
  3. Serverless is the same story without a signal: once the response is sent, the platform may freeze or reuse the instance. after() passes the work to the platform's waitUntil, which is what keeps the invocation alive.
  4. Give your orchestrator a drain period long enough for this (the docs suggest 10–30 seconds), or a SIGKILL cuts the callbacks off anyway.
Measured on next start: when the user gets the response, and when the 1.5 s track() call finishes after() track() runs after the response, done 1506 ms response 27 ms await track() user waits for track() response 1504 ms after() + SIGTERM server waits for the callback exit 1553 ms void promise + SIGTERM exit 72 ms: track() lost 0 500 ms 1000 ms 1500 ms
The same 1.5 s of work in four setups. Only after() gives a fast response and a finished job.
Advancedafter() on errors and in components

Errors, request data and Server Components

after() callbacks run even when the response failed:

app/api/broken/route.ts
import { after } from "next/server";
import { cookies } from "next/headers";

export async function POST() {
  after(async () => {
    // In a Route Handler, request APIs still work inside the callback
    const session = (await cookies()).get("session")?.value ?? "anonymous";
    console.log(`[after] logging the failure for ${session}`);
  });
  throw new Error("payment provider timed out");
}
What’s happening
  1. The callback is registered first, then the handler throws. curl -X POST -b session=u_42 localhost:4508/api/broken got HTTP/1.1 500 Internal Server Error.
  2. The server log showed the error and then the callback: ⨯ Error: payment provider timed out, followed by [after] logging the failure for u_42. The docs say the same holds when the code calls notFound() or redirect(), which makes after() a good place for audit logging.
  3. Inside a Route Handler (or a Server Action), cookies() and headers() can be called inside the callback: the request context is still available. That's not true in Server Components, below.

In a Server Component, read request data during render and pass the values into the callback. With Cache Components, the component that reads cookies goes inside <Suspense> so the rest of the page can be prerendered:

app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { after } from "next/server";

async function Greeting() {
  // Read request data during render...
  const user = (await cookies()).get("session")?.value ?? "guest";
  // ...and hand the value to after() through the closure
  after(() => console.log(`[after] account viewed by ${user}`));
  return <p>Signed in as {user}</p>;
}

export default function Account() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
What’s happening
  1. /account is ◐ in the route table: the <h1> and the fallback are the static shell, and Greeting streams in per request because it reads cookies().
  2. Greeting reads the cookie while rendering, which is allowed, and captures user in the closure it passes to after().
  3. Requests with session=alice, bob and carol returned Signed in as alice (and so on) and logged [after] account viewed by alice etc. twice per request on 16.4.3 (alice, alice, bob, bob, carol, carol), while the Route Handler callbacks above ran once. The cause is Partial Prefetching (partialPrefetching: true, the template's setting): after the normal render of a dynamic request, Next.js renders the page a second time as a "runtime prerender" and embeds the result in the RSC payload (chapter 04 traces it), and Greeting calls after() in both passes. Rebuilt with partialPrefetching: false, the same three requests logged alice, bob, carol, once each. Since you don't control how many times a component renders, make callbacks idempotent (an event id the analytics store dedupes on) rather than assuming exactly-once in Server Components.

The wrong version calls cookies() inside the callback. It builds, and fails at runtime:

app/account-wrong/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { after } from "next/server";

async function Greeting() {
  await cookies(); // makes this component dynamic
  after(async () => {
    const user = (await cookies()).get("session")?.value; // ✗ throws: request API inside after() in a Server Component
    console.log(`[after] account viewed by ${user}`);
  });
  return <p>Hello</p>;
}

export default function AccountWrong() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Greeting />
    </Suspense>
  );
}
What’s happening
  1. next build succeeded (◐ /account-wrong): nothing checks this at build time.
  2. The page still rendered (200, <p>Hello</p>), because the error happens after the response.
  3. The server log showed the error instead of the analytics line: An error occurred in a function passed to `after()`: Error: Route /account-wrong used `cookies()` inside `after()` while rendering. This is not supported. If you need this data inside an `after()` callback, use `cookies()` outside of the callback.
  4. The reason, from the docs: with Partial Prerendering and Cache Components, Next.js has to know which components read request data while React renders, and after() runs after rendering has finished. So in components the rule is "read during render, use in after()".
Where does this log appear?

app/page.tsx in a Cache Components app:

app/page.tsx
import { after } from "next/server";

export default function Home() {
  after(() => {
    console.log(`[after] home page rendered at ${new Date().toISOString()}`);
  });
  return <h1>Home</h1>;
}

You run next build, then next start, then curl localhost:4508/ three times. How many [after] lines do you see, and where?

Show answer

They appear in the build output, not in the server log, and none appear for the three requests:

Output
$ next build
  …
  Generating static pages using 10 workers (6/9)
[after] home page rendered at 2026-10-25T19:08:02.800Z
[after] home page rendered at 2026-10-25T19:08:02.801Z
✓ Generating static pages using 10 workers (9/9) in 222ms
  Finalizing page optimization ...
…
┌ ○ /

$ curl -s -D - -o /dev/null localhost:4508/
x-nextjs-cache: HIT
(server log: no [after] line)
What’s happening
  1. after() is not a request-time API, so calling it doesn't make the page dynamic. The page reads nothing from the request, so it's prerendered: ○ /.
  2. Prerendering is a render, and after() callbacks run when a render finishes, so the callback ran during next build. It printed twice in my build (the build rendered the page twice), with timestamps 1 ms apart, while the static pages were being generated. It printed twice with partialPrefetching: false as well, so this isn't the runtime prerender from the /account example; I didn't trace which two build passes they are.
  3. new Date() inside the callback was fine. Reading the clock is only a problem in the render itself; the callback runs after rendering.
  4. At runtime every request was a cache HIT: Next.js sent the stored HTML without rendering, so no callback ran. It would run again only when the page is revalidated and re-rendered.
  5. So "log every page view" with after() in a static page doesn't work. Count views where something runs per request: a dynamic component inside <Suspense>, a Route Handler the client calls, event.waitUntil or after() in proxy, or client-side analytics.
AdvancedRoute table for the after() app
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /account
├ ƒ /api/broken
├ ƒ /api/checkout
├ ƒ /api/checkout-blocking
└ ƒ /api/checkout-floating


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedWhen after() isn't enough

after() keeps work alive for the platform's maximum duration of the route (configurable with the maxDuration segment config where the platform supports it), and on a self-hosted server until shutdown finishes draining. It isn't a job queue: no retries, no persistence if the process crashes, no scheduling. Use it for best-effort side effects that may occasionally be lost (analytics, logs, cache warming). For work that must happen (sending the order confirmation email, charging a card, fulfilment), write a job to a queue or an outbox table inside the request, and let a worker process it. In proxy, the older equivalent is event.waitUntil(promise) on the NextFetchEvent second argument; the docs show both, and I didn't run either in proxy here.

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.