Route Handlers & Backend for Frontend

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router, where an endpoint is a route.ts file (a Route Handler), and the Pages Router, where it is a file under pages/api (an API Route); the two are compared in a dedicated section. Where caching changes the outcome, the page labels the two models: the previous model (the default, cacheComponents off) and Cache Components (cacheComponents: true, opt-in). Every example was built with next build and exercised with curl against next start on Next.js 16.3.8 and Node.js 24.16 (macOS), in a scratch copy of the Bookshelf catalogue that the rest of this section grows. A tiny stand-in for the Bookshelf API (a plain Node.js script that answers /books, /books/<id> and /books/<id>/reviews) played the part of the FastAPI, NestJS or Django backend, and the status codes and headers quoted below are the real output, not paraphrases of the documentation.

A page answers "what should the browser show?". An endpoint answers "what should this HTTP client receive?": JSON for a mobile app, a signed acknowledgement for a payment provider, an event stream, a file, a redirect. Next.js gives you one file convention for that, route.ts, built directly on the Web Request and Response objects, and a well-known architectural role to play with it: the backend for frontend (BFF), a thin server that belongs to the front end and stands between the browser and the real backends. The page starts with the convention, goes through the request and response helpers, caching, streaming and webhooks, then CORS, then the BFF pattern, which is the part that ties it to the FastAPI, NestJS and Django guides on this site, and ends with the Pages Router equivalent, a security checklist, the recent changes and the places where older Next.js books mislead.

Where Server-Side Code Can Live

A Next.js application has several places to run code on the server, and a Route Handler is the right one only for some jobs. The siblings of this page cover the others in depth: data fetching in Server Components, Server Actions and mutations, and caching are described on their own pages in this section; the Proxy is described in Redirects, Rewrites and Proxy.

Table 1. Choosing between the server-side entry points of Next.js 16.3
Mechanism Use it for Do not use it for

Server Component (async component)

Reading data to render a page. Call the data layer or the backend directly.

Calling your own Route Handler: it adds an HTTP round trip, and when the page is prerendered at build time it fails (see When not to use a Route Handler: your own Server Components).

Server Action ('use server')

Mutations triggered by your own UI: forms, buttons, optimistic updates. It is a POST the framework wires for you.

An endpoint that other clients (mobile apps, partners, webhooks) must call with a stable URL and method.

Route Handler (route.ts)

A public HTTP endpoint: JSON or any other content type, webhooks, streaming, files, CORS, a BFF, health checks.

Rendering HTML pages (use a page.tsx), or reading data for your own Server Components.

proxy.ts

Cheap decisions before a route runs: redirects, rewrites, headers, coarse gates.

Authorization on its own: it can be bypassed or misconfigured, so verify credentials in the handler too.

rewrites in next.config.ts

A pure pass-through to another origin, with no logic.

Anything that needs validation, a token or a changed payload.

API Route (pages/api)

The same job as a Route Handler in a Pages Router application.

New App Router code.

The route.ts Convention

A Route Handler is a file named route.ts (or route.js) inside the app folder, in any segment, exactly where a page.tsx could be. The folder path is the URL. It exports one function per HTTP method it supports, and each function receives a Request and returns a Response. These are the platform’s Web APIs, the same ones the browser and Deno and Cloudflare Workers use, so there is nothing Next.js specific to learn about reading a body or building a status line.

app/
  api/
    books/
      route.ts            GET /api/books, POST /api/books
      [id]/
        route.ts          GET /api/books/3, DELETE /api/books/3
  rss.xml/
    route.ts              GET /rss.xml  (a file-like URL, no page involved)

The rules are few and each has a visible failure:

  • Methods. GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS are supported. A method that the file does not export gets 405 Method Not Allowed.

  • OPTIONS and HEAD are implemented for you. If a file exports GET and DELETE, OPTIONS answers 204 with allow: DELETE, GET, HEAD, OPTIONS, and HEAD answers like GET without a body. Export your own OPTIONS to take over (the CORS section does).

  • A segment is a route or a page, not both. A route.ts next to a page.tsx in the same folder stops the build: Conflicting route and page at /conflict: route at /conflict/route and page at /conflict/page. Put endpoints under a folder such as api or a file-like name such as rss.xml, or in a different segment of the tree.

  • Handlers are not part of the layout tree. They do not take part in layouts, templates or client-side navigations, so nothing wraps them.

  • Every handler is a public URL. Anyone who can reach the server can send any method to it, with any body. Treat the handler as an API endpoint, not as an internal function.

  • Segment config. dynamicParams, runtime and maxDuration can be exported from a Route Handler. runtime = 'edge' and preferredRegion are deprecated; the Node.js runtime is the default.

The second argument is a context whose params is a promise (since Next.js 15), because dynamic segments are request-time data. In TypeScript the globally available RouteContext helper types it from the route literal, so a typo in [id] is a compile error. The helper is generated by next dev, next build or next typegen: until one has run, tsc reports Cannot find name 'RouteContext', exactly as it does for PageProps and LayoutProps.

A Route Handler for one book, with a typed context and server-side authorization (app/api/books/[id]/route.ts)
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
import { deleteBook, getBook } from "@/lib/books";

export async function GET(_request: NextRequest, ctx: RouteContext<"/api/books/[id]">) {
  const { id } = await ctx.params;
  const book = await getBook(id);
  if (!book) return Response.json({ error: "Book not found" }, { status: 404 });
  return Response.json(book);
}

export async function DELETE(_request: NextRequest, ctx: RouteContext<"/api/books/[id]">) {
  const session = await auth();
  if (!session?.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
  const { id } = await ctx.params;
  return (await deleteBook(id))
    ? new Response(null, { status: 204 })
    : Response.json({ error: "Book not found" }, { status: 404 });
}

Requesting it with curl shows the contract. PATCH is not exported, so it gets 405; the OPTIONS answer is the one Next.js wrote; the unauthenticated DELETE is refused before any data is touched, and a request with a valid Auth.js session cookie (obtained from the credentials sign-in of the scratch app) succeeds once and then reports the book gone.

$ curl -s localhost:3604/api/books/2
{"id":"2","title":"Neuromancer","author":"William Gibson","priceCents":899}

$ curl -si localhost:3604/api/books/99
HTTP/1.1 404 Not Found
{"error":"Book not found"}

$ curl -si -X PATCH localhost:3604/api/books/2
HTTP/1.1 405 Method Not Allowed

$ curl -si -X OPTIONS localhost:3604/api/books/2
HTTP/1.1 204 No Content
allow: DELETE, GET, HEAD, OPTIONS

$ curl -si -X DELETE localhost:3604/api/books/2
HTTP/1.1 401 Unauthorized
{"error":"Unauthorized"}

$ curl -si -b cookies.txt -X DELETE localhost:3604/api/books/3    # signed in
HTTP/1.1 204 No Content
$ curl -si -b cookies.txt -X DELETE localhost:3604/api/books/3    # again
HTTP/1.1 404 Not Found

Note that the 405 response of Next.js 16.3.8 carries no Allow header (only the automatic OPTIONS answer does); a client that needs the list of methods must ask with OPTIONS, or the handler must answer 405 itself.

The same convention scales down to tiny endpoints. create-next-app --api generates a project with only Route Handlers (no layout and no page), described in Getting Started. Dynamic segments, catch-all segments and RouteContext are the same ones described in Dynamic Routes and Route Groups, and the route tree and the app/api folder in Project Structure and Routing Fundamentals.

NextRequest and NextResponse

NextRequest extends the Web Request and NextResponse extends the Web Response. You can pass a NextRequest anywhere a Request is expected and return a NextResponse anywhere a Response is expected, so using them is optional; they exist for the conveniences below, and they are the same classes the Proxy uses.

Table 2. What the two extensions add in Next.js 16.3
API What it gives you

request.nextUrl

A parsed URL with pathname, searchParams, basePath and buildId. The i18n properties of the Pages Router are not available in the App Router.

request.cookies

get, getAll, has, set, delete, clear on the cookies of the incoming request.

NextResponse.json(body, init)

A JSON response with the right Content-Type. Response.json() from the platform does the same.

NextResponse.redirect(url) and NextResponse.rewrite(url)

A redirect response, or a rewrite that serves another path under the original URL. The Proxy is their main home.

response.cookies.set(name, value, options)

Adds a Set-Cookie header with typed options (httpOnly, secure, sameSite, maxAge, path).

NextResponse.next()

Meant for the Proxy ("continue routing"); a Route Handler has nothing to continue to.

cookies() and headers() from next/headers

Async request-scoped readers that also work in Server Components and Server Actions. headers() is read-only: to send a header, put it on the Response.

A handler that reads query parameters, validates a JSON body and answers with a status code and a Location header is the bread and butter of the convention. The validation matters: the body is whatever the client sent, and a TypeScript type is not a runtime check.

Listing and creating books (app/api/books/route.ts)
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createBook, listBooks } from "@/lib/books";

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get("q") ?? "";
  return NextResponse.json(await listBooks(q));
}

const NewBook = z.object({
  title: z.string().trim().min(1).max(200),
  author: z.string().trim().min(1).max(200),
  priceCents: z.number().int().min(0).max(100_000),
});

export async function POST(request: NextRequest) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
  }
  const parsed = NewBook.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid book", issues: z.flattenError(parsed.error).fieldErrors },
      { status: 422 },
    );
  }
  const book = await createBook(parsed.data);
  return NextResponse.json(book, {
    status: 201,
    headers: { Location: new URL(`/api/books/${book.id}`, request.url).toString() },
  });
}
$ curl -s "localhost:3604/api/books?q=dune"
[{"id":"1","title":"Dune","author":"Frank Herbert","priceCents":999}]

$ curl -si -X POST localhost:3604/api/books -H 'content-type: application/json' \
    -d '{"title":"Hyperion","author":"Dan Simmons","priceCents":1099}'
HTTP/1.1 201 Created
location: http://localhost:3604/api/books/4
{"id":"4","title":"Hyperion","author":"Dan Simmons","priceCents":1099}

$ curl -si -X POST localhost:3604/api/books -H 'content-type: application/json' \
    -d '{"title":"","author":"x","priceCents":-1}'
HTTP/1.1 422 Unprocessable Entity
{"error":"Invalid book","issues":{"title":["Too small: expected string to have >=1 characters"],"priceCents":["Too small: expected number to be >=0"]}}

$ curl -si -X POST localhost:3604/api/books -d 'nope'
HTTP/1.1 400 Bad Request
{"error":"Body must be JSON"}

Four details of the body API are worth knowing before they bite:

  • One read per body. request.json(), request.text() and request.formData() consume the stream; reading again throws. Call request.clone() first if two readers need it.

  • GET and HEAD have no body. Do not read one.

  • formData() values are strings (or files); validate and convert them as with the JSON body.

  • No bodyParser and no size option in the App Router. The Pages Router parser and its sizeLimit have no Route Handler equivalent, so limit the size you accept yourself (check Content-Length, or read the stream with a cap) before buffering untrusted input.

Cookies and headers have two spellings, the Web way on the request and response objects and the next/headers way. A handler that reads a cookie and a header and sets a hardened cookie looks like this.

Reading and setting cookies and headers (app/api/headers-demo/route.ts)
import { cookies, headers } from "next/headers";
import { NextResponse, type NextRequest } from "next/server";

export async function GET(request: NextRequest) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
  const headersList = await headers();
  const response = NextResponse.json({
    theme,
    viaRequestCookies: request.cookies.get("theme")?.value ?? null,
    userAgent: headersList.get("user-agent"),
    requestId: request.headers.get("x-request-id"),
  });
  response.cookies.set("seen", "1", { httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge: 60 * 60 });
  response.headers.set("Cache-Control", "private, no-store");
  return response;
}
$ curl -si localhost:3604/api/headers-demo -H 'Cookie: theme=dark' -H 'x-request-id: abc-1'
HTTP/1.1 200 OK
cache-control: private, no-store
set-cookie: seen=1; Path=/; Expires=Sat, 03 Oct 2026 16:03:59 GMT; Max-Age=3600; Secure; HttpOnly; SameSite=lax
{"theme":"dark","viaRequestCookies":"dark","userAgent":"curl/8.7.1","requestId":"abc-1"}

A handler can also end with a redirect, either NextResponse.redirect(new URL('/', request.url)) or redirect() from next/navigation, which works by throwing (so it must not be inside a try block that swallows the error). Never build the destination from an unvalidated query parameter: the official callback example compares destination.origin with request.nextUrl.origin to prevent an open redirect, and the same goes for req.headers.origin, which a client can set to anything. Redirect status codes and the proxy.ts side of this are in Redirects, Rewrites and Proxy.

Caching GET Handlers: Two Models

Only GET can be cached; POST, PUT, PATCH, DELETE and OPTIONS never are, even next to a cached GET in the same file. What GET does depends on which caching model the application runs, and the two differ in the direction of the default.

Previous model (cacheComponents off, the default)

Since Next.js 15 a GET handler is not cached by default: it runs on every request. (In Next.js 13.2 to 14 it was cached unless it used a dynamic function; the change is in the 15.0 release, see What Changed Recently.) To cache it, opt in with the route segment config, the same dynamic and revalidate options that pages use. fetch calls inside the handler follow the usual rules of the previous model: they are not cached unless asked (cache: 'force-cache' or next: { revalidate, tags }).

The two previous-model handlers and a tagged fetch (app/api/cache/*/route.ts, previous model)
// app/api/cache/static/route.ts  -- opted in: built once, refreshed at most once a minute
export const dynamic = "force-static";
export const revalidate = 60;

export async function GET() {
  return Response.json({ generatedAt: new Date().toISOString() });
}
// app/api/cache/dynamic-default/route.ts  -- no config: runs on every request
export async function GET() {
  return Response.json({ generatedAt: new Date().toISOString() });
}
// app/api/cache/tagged/route.ts  -- a static handler whose upstream fetch carries a tag
import { BOOKSHELF_API_URL } from "@/lib/bookshelf-api";

export const dynamic = "force-static";

export async function GET() {
  const res = await fetch(`${BOOKSHELF_API_URL}/books`, { next: { tags: ["books"] } });
  return Response.json({ fetchedAt: new Date().toISOString(), books: await res.json() });
}

The next build route table already tells the three apart: ○ (static) with the Revalidate column for the first, ƒ for the second, and ○ for the tagged one (it was built once, calling the upstream during the build).

├ ƒ /api/cache/dynamic-default
├ ○ /api/cache/static                   1m      1y
├ ○ /api/cache/tagged

Requests show the behaviour: the static handler keeps answering with the build-time timestamp (x-nextjs-cache: HIT, cache-control: s-maxage=60, stale-while-revalidate=31535940), the default one gives a new value each time. After a revalidateTag("books", "max") call (the webhook below), the tagged handler serves the stale copy once and the next request already shows a new fetchedAt: the max profile means stale-while-revalidate.

$ curl -s localhost:3604/api/cache/static; sleep 1; curl -s localhost:3604/api/cache/static
{"generatedAt":"2026-10-03T15:03:44.660Z"}
{"generatedAt":"2026-10-03T15:03:44.660Z"}

$ curl -s localhost:3604/api/cache/dynamic-default; sleep 1; curl -s localhost:3604/api/cache/dynamic-default
{"generatedAt":"2026-10-03T15:04:01.627Z"}
{"generatedAt":"2026-10-03T15:04:02.645Z"}

Cache Components (cacheComponents: true)

With Cache Components a GET handler behaves like any route of the application: it is prerendered at build time as long as it touches nothing that is only known at request time, and the moment it does, prerendering stops and the handler runs per request. There is no per-file switch: export const dynamic, revalidate and fetchCache are removed (Next.js 16.0) and the build refuses them:

Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.

What ends prerendering (the handler becomes ƒ): network requests, database queries, async file system calls, reading the Request (request.url, request.headers, request.cookies, request.body), the runtime APIs cookies(), headers() and connection(), and non-deterministic operations such as Math.random() and new Date(). What you do about it is the same as for components: put the uncached work behind the use cache directive in a helper function (the directive cannot sit in the handler body), give it a lifetime with cacheLife and a tag with cacheTag, and the handler is prerendered again with that data in it.

The four Cache Components cases (app/api/cache/*/route.ts, Cache Components)
// static/route.ts -- touches nothing dynamic: prerendered at build time
export async function GET() {
  return Response.json({ projectName: "Bookshelf", version: 1 });
}
// random/route.ts -- new Date() is non-deterministic: prerendering stops, runs per request
export async function GET() {
  return Response.json({ generatedAt: new Date().toISOString() });
}
// runtime/route.ts -- a runtime API: per request
import { headers } from "next/headers";

export async function GET() {
  const userAgent = (await headers()).get("user-agent");
  return Response.json({ userAgent });
}
// cached/route.ts -- uncached data made cacheable: prerendered, refreshed on demand or by cacheLife
import { cacheLife, cacheTag } from "next/cache";
import { BOOKSHELF_API_URL } from "@/lib/bookshelf-api";

export async function GET() {
  return Response.json(await getCatalogue());
}

// "use cache" cannot go in the handler body: extract it to a helper
async function getCatalogue() {
  "use cache";
  cacheLife("hours");
  cacheTag("books");
  const res = await fetch(`${BOOKSHELF_API_URL}/books`);
  return { cachedAt: new Date().toISOString(), books: await res.json() };
}

The route table of the Cache Components build (with the mock backend running, as the cached handler fetches it at build time) and the requests confirm it. The cached handler is ○ with a one-hour revalidation and a one-day expiry; the CMS webhook below (revalidateTag("books", "max")) refreshes it:

├ ○ /api/cache/cached                  1h      1d
├ ƒ /api/cache/random
├ ƒ /api/cache/runtime
├ ○ /api/cache/static
$ curl -si localhost:3604/api/cache/static | grep -iE "cache-control|x-nextjs"
x-nextjs-cache: HIT
cache-control: s-maxage=31536000

$ curl -si localhost:3604/api/cache/cached | grep -iE "cache-control|x-nextjs"
x-nextjs-cache: HIT
cache-control: s-maxage=3600, stale-while-revalidate=82800

$ curl -s localhost:3604/api/cache/random; sleep 1; curl -s localhost:3604/api/cache/random
{"generatedAt":"2026-10-03T15:05:26.609Z"}
{"generatedAt":"2026-10-03T15:05:27.632Z"}
Table 3. Caching a GET Route Handler in the two models
Previous model (default) Cache Components

Default for GET

Runs per request (since 15.0)

Prerendered if nothing dynamic is touched, per request otherwise

Opt in to caching

export const dynamic = 'force-static', revalidate, fetch options

use cache in a helper, with cacheLife and cacheTag

Route segment config dynamic, revalidate, fetchCache

Supported

Build error: remove them

Invalidate

revalidateTag, revalidatePath, or revalidate time

revalidateTag, updateTag (in Server Actions), revalidatePath, or cacheLife

Other methods (POST and so on)

Never cached

Never cached

Whichever model is on, do not cache what depends on the caller. A handler that returns per-user data must read a runtime API (so it is dynamic) and say Cache-Control: private, no-store to every cache between the server and the browser. The ISR and use cache machinery behind these options is explained in Incremental Static Regeneration and Partial Prerendering and Cache Components; the dedicated caching page of this section compares the cache layers.

Streaming Responses

Because the return value is a Web Response, anything that is a ReadableStream can be the body, and the bytes leave the server as they are produced. A ReadableStream of text events with the text/event-stream content type is the minimal Server-Sent Events endpoint, which a browser consumes with EventSource. (The same body type is how AI SDKs stream model output through a handler.)

A Server-Sent Events endpoint (app/api/stream/route.ts)
const encoder = new TextEncoder();

export async function GET(request: Request) {
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      for (let n = 1; n <= 5; n++) {
        if (request.signal.aborted) break;
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ n, at: new Date().toISOString() })}\n\n`));
        await new Promise((r) => setTimeout(r, 400));
      }
      controller.close();
    },
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
    },
  });
}

curl -N (no buffering) with a timestamp added to each line shows the events leaving about 400 ms apart instead of arriving together after two seconds:

$ curl -sN localhost:3604/api/stream | while IFS= read -r l; do echo "$(date +%S.%N | cut -c1-6) $l"; done
03.697 data: {"n":1,"at":"2026-10-03T15:04:03.694Z"}
04.099 data: {"n":2,"at":"2026-10-03T15:04:04.096Z"}
04.501 data: {"n":3,"at":"2026-10-03T15:04:04.498Z"}
04.904 data: {"n":4,"at":"2026-10-03T15:04:04.900Z"}
05.302 data: {"n":5,"at":"2026-10-03T15:04:05.300Z"}

This route is ƒ in both models (it reads request.signal and the clock). Four practical points:

  • Stop the work when the client leaves. request.signal is aborted when the connection closes; check it (or pass it to fetch) so an abandoned stream does not keep a loop, a database cursor or an upstream call alive. (With curl --max-time 1 the loop above noticed the closed connection at its fourth iteration and stopped.)

  • Something downstream may buffer. A reverse proxy, a CDN or compression middleware can hold a streamed body until it is complete. Cache-Control: no-cache, no-transform helps, and on nginx the X-Accel-Buffering: no response header and proxy_buffering off are the usual switches; the self-hosting guide lists the settings for Next.js.

  • Duration limits apply on serverless platforms. Use maxDuration and the platform’s limits, and prefer short-lived streams.

  • A Route Handler can stream an upstream body straight through (new Response(upstream.body)), which is what the BFF below does for the Bookshelf events endpoint. For streaming pages instead of data, see Streaming and Suspense.

Webhooks and Callback URLs

A webhook is a POST from a third party to a URL of yours: a payment provider announcing a paid invoice, a CMS announcing a published article, a Git host announcing a push. Route Handlers are the natural home. Three rules decide whether the endpoint is safe: verify who sent it, verify it on the exact bytes received, and answer fast and do the work once.

A Stripe webhook with signature verification

Stripe signs every event with a secret that belongs to the endpoint and sends the result in the Stripe-Signature header. The signature covers the raw body, so the handler reads request.text() (never request.json(), which would re-serialise differently) and passes the text, the header and the secret to the library’s constructEvent, which also rejects replayed events outside a time tolerance. The App Router has no body parser to switch off, unlike the Pages Router (see Pages Router: API Routes). The secret and the API key come from the environment, never from source code and never from a NEXT_PUBLIC_ variable.

Verifying and acknowledging a Stripe event (app/api/webhooks/stripe/route.ts)
import Stripe from "stripe";
import { after } from "next/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  // The signature covers the exact bytes Stripe sent: read the raw text, never request.json()
  const payload = await request.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    // Acknowledge quickly, fulfil after the response; make the work idempotent on event.id
    after(async () => {
      console.log(`fulfil order for session ${session.id} (event ${event.id})`);
    });
  }
  return new Response(null, { status: 200 });
}

The example was run with the stripe package (version 23.0.0 on 2026-10-03), the placeholders STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET set to obvious dummy values in .env.local, and a signature header produced by the library’s generateTestHeaderString test helper over the same payload and secret. A correct signature is acknowledged; a payload changed by one character, or a missing header, is refused with 400, and only the valid event reached the fulfilment code:

$ curl -si -X POST localhost:3604/api/webhooks/stripe -H "stripe-signature: $SIG" -d "$PAYLOAD"
HTTP/1.1 200 OK

$ curl -si -X POST localhost:3604/api/webhooks/stripe -H "stripe-signature: $SIG" -d "$TAMPERED_PAYLOAD"
HTTP/1.1 400 Bad Request
Invalid signature

$ curl -si -X POST localhost:3604/api/webhooks/stripe -d "$PAYLOAD"
HTTP/1.1 400 Bad Request
Missing signature

# server log, after the first request only:
fulfil order for session cs_test_1 (event evt_test_1)

Why this shape:

  • Fulfil from the webhook, not from the success page. Landing on the "thank you" URL after a checkout redirect proves nothing: anyone can type it. Payment is confirmed by the signed event, and the order is marked paid there. Older books treat the redirect as proof of payment; see Where the Books Are Outdated.

  • Return 2xx quickly. Providers retry on timeouts and non-2xx answers, so slow work belongs after the response. after() (stable since Next.js 15.1) runs a callback once the response has been sent; on platforms with background limits, queue the work instead.

  • Be idempotent. Providers deliver at least once and out of order. Key the work on the event id and ignore an id you have already processed.

  • Do not log the payload or the signature header, and reject unknown event types quietly with 200 so the provider does not retry them forever.

  • Keep the secret per endpoint and per environment. Rotate it from the provider’s dashboard.

A shared-secret webhook that revalidates the cache

Content systems usually do not sign their webhooks; they let you configure a URL and a header. The official guide shows a revalidation endpoint guarded by a secret token. Two improvements make it safe to copy: take the secret from a header (query strings end up in access logs and proxies) and compare it in constant time, and use POST, because the call changes state. revalidateTag takes a cache life profile as its second argument in Next.js 16; "max" gives stale-while-revalidate.

A secret-guarded revalidation webhook (app/api/webhooks/cms/route.ts)
import { revalidateTag } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";

function sameSecret(received: string | null, expected: string | undefined) {
  if (!received || !expected) return false;
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function POST(request: NextRequest) {
  if (!sameSecret(request.headers.get("x-webhook-secret"), process.env.REVALIDATE_SECRET_TOKEN)) {
    return NextResponse.json({ success: false }, { status: 401 });
  }
  const { tag } = (await request.json().catch(() => ({}))) as { tag?: string };
  if (!tag) return NextResponse.json({ success: false }, { status: 400 });
  revalidateTag(tag, "max");
  return NextResponse.json({ success: true, revalidated: tag });
}
$ curl -si -X POST localhost:3604/api/webhooks/cms -H 'content-type: application/json' -d '{"tag":"books"}'
HTTP/1.1 401 Unauthorized
{"success":false}

$ curl -s -X POST localhost:3604/api/webhooks/cms -H 'x-webhook-secret: <the placeholder secret>' \
    -H 'content-type: application/json' -d '{"tag":"books"}'
{"success":true,"revalidated":"books"}

The pairing with the cache handlers of the previous section was observed in both models: the first request after the call still returned the old fetchedAt or cachedAt (stale-while-revalidate), and the next one showed a new timestamp.

Callback URLs follow the same pattern. When a third-party flow (an OAuth provider, a payment page) redirects the user back to you, a handler verifies the response, sets a session cookie with httpOnly, secure and sameSite, and redirects to a destination that has been checked against your own origin. For OAuth, prefer a library such as Auth.js, whose app/api/auth/[…​nextauth]/route.ts is exactly such a catch-all handler, over writing the flow by hand.

CORS

A Route Handler on the same origin as the page that calls it needs no CORS configuration, and that is the common case when the handler is a BFF. CORS enters when another origin’s JavaScript (a separate front end on a different domain or port, a partner site, a mobile web view) calls your endpoint from a browser. The mechanism, the simple versus preflighted split, the response headers and the common mistakes are explained in CORS; this section only shows how to express it in a handler. Without CORS headers the response is same-origin only by default: the server still executes the request and answers, it is the browser that withholds the response from the other origin’s script.

Next.js writes the OPTIONS answer for you, but it does not add CORS headers, so a cross-origin call with a preflight needs your own OPTIONS export. The safe form echoes the origin only if it is in an explicit allow-list, adds Vary: Origin so shared caches keep per-origin answers apart, and never combines a wildcard with credentials. The official example sets *, which is right only for genuinely public, credential-less data.

CORS with an allow-list for one endpoint (app/api/cors/books/route.ts)
import { type NextRequest } from "next/server";
import { listBooks } from "@/lib/books";

const ALLOWED_ORIGINS = new Set(["https://shop.example.test", "http://localhost:3001"]);

function corsHeaders(origin: string | null): Headers {
  const headers = new Headers({ Vary: "Origin" });
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    headers.set("Access-Control-Allow-Origin", origin);
  }
  return headers;
}

export async function OPTIONS(request: NextRequest) {
  const headers = corsHeaders(request.headers.get("origin"));
  if (headers.has("Access-Control-Allow-Origin")) {
    headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
    headers.set("Access-Control-Allow-Headers", "Content-Type");
    headers.set("Access-Control-Max-Age", "600");
  }
  return new Response(null, { status: 204, headers });
}

export async function GET(request: NextRequest) {
  const headers = corsHeaders(request.headers.get("origin"));
  headers.set("Content-Type", "application/json");
  return new Response(JSON.stringify(await listBooks(request.nextUrl.searchParams.get("q") ?? "")), { headers });
}

Source: route.js: CORS

$ curl -si localhost:3604/api/cors/books -H 'Origin: http://localhost:3001' | grep -iE "^HTTP|access-control"
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:3001

$ curl -si -X OPTIONS localhost:3604/api/cors/books -H 'Origin: http://localhost:3001' \
    -H 'Access-Control-Request-Method: GET' | grep -iE "^HTTP|access-control"
HTTP/1.1 204 No Content
access-control-allow-headers: Content-Type
access-control-allow-methods: GET, OPTIONS
access-control-allow-origin: http://localhost:3001
access-control-max-age: 600

$ curl -si -X OPTIONS localhost:3604/api/cors/books -H 'Origin: https://evil.example' \
    -H 'Access-Control-Request-Method: GET' | grep -iE "^HTTP|access-control"
HTTP/1.1 204 No Content

The disallowed origin gets no Access-Control- header at all, so its browser blocks the call. To apply the same headers to every handler under a prefix without repeating the code, use the headers key of next.config.ts or the Proxy (both documented under CORS in the official reference), and test the preflight, not only the GET. The same policy on the backends themselves is described for NestJS in Security Headers, CORS and CSRF and for FastAPI in Middleware, CORS and Proxies. Remember the rule of CORS: it controls which browsers may *read a response, it is not authentication.

The Backend for Frontend Pattern

A backend for frontend is a server-side layer built for one front end. The browser talks only to it, over the same origin as the pages; it talks to the real backends over a private network, with credentials the browser never holds. In Next.js the layer is made of Route Handlers (and Server Actions for the app’s own mutations) living in the same project as the pages. The Next.js documentation presents it as an API layer that is publicly reachable, handles any HTTP request and can return any content type, and says plainly that it is not a replacement for a full backend.

flowchart LR browser(["Browser
pages + client code"]) subgraph next["Next.js server (BFF)"] rsc["Server Components
fetch the API directly"] rh["Route Handlers
/api/bff/..., allow-list, token, aggregation"] end subgraph back["Bookshelf API (one of)"] fa["FastAPI :8000"] ne["NestJS :3000"] dj["Django REST :8000"] end browser -->|"HTML, RSC payload"| rsc browser -->|"same-origin fetch, httpOnly cookie"| rh rsc -->|"server-side fetch"| back rh -->|"validated path, Bearer token"| back

Why put the layer there:

  • Tokens stay out of the browser. The OAuth guidance for browser-based apps recommends exactly this topology: the server holds the access and refresh tokens, the browser holds only an httpOnly session cookie, and cross-site scripting cannot steal what JavaScript cannot read. The reasoning, the token-mediating variant and the residual risks are in OAuth for Browser-Based Apps.

  • No CORS. The browser only ever calls its own origin, so the backend does not need to trust the front end’s origin (it can still configure it for development).

  • One round trip instead of three. A handler can fetch several backend resources in parallel and return the shape the screen needs.

  • The backend’s address and shape are hidden. Internal host names, ports, field names and error formats do not leak, and can change without breaking the browser.

  • A place for cross-cutting policy. Rate limiting, request validation, logging and error mapping live in one layer.

The Bookshelf backends of this site all fit behind it. Only the base URL and the paths differ, and the base URL is the only thing that needs configuration:

Table 4. The three Bookshelf backends as upstreams
Backend Default address Books endpoints

FastAPI

http://127.0.0.1:8000 (fastapi run)

/books/{book_id} in the getting-started examples; the larger Bookshelf API versions its paths

NestJS

http://localhost:3000 (PORT variable)

GET /books, POST /books, as in NestJS vs Next.js

Django REST framework

http://127.0.0.1:8000 (runserver)

/api/v1/books/ (with the trailing slash that DRF routers expect)

The NestJS comparison page already shows the thin version of this layer (a data-access module read by Server Components, a route handler that reshapes the answer, and a Server Action that forwards a form), and how to decide between a Next.js-only backend and a separate NestJS service; read it with NestJS vs Next.js. This section adds the part that page leaves out: the handler that forwards browser requests safely.

A safe, allow-listed proxy

The risky part of any BFF is the generic forwarder, the catch-all handler that turns /api/bff/<anything> into <backend>/<anything>. It is attractive (one file, no maintenance) and it is a vulnerability unless it is built around an allow-list. The version below applies five rules:

  1. The origin of the upstream URL is fixed in configuration; the client chooses only a path and a query string.

  2. The path must match an explicit pattern for a known resource, so /admin/users, .. segments and unknown routes are rejected with 404 before any request leaves the server.

  3. The method must be allowed for that pattern; others get 405.

  4. The outgoing headers are built from scratch: the browser’s Cookie, Authorization and x-* headers are never forwarded, and the backend token is attached from the session on the server.

  5. The response is rebuilt, so upstream Set-Cookie and internal headers do not reach the browser; the call has a timeout, redirects are not followed, and a failure becomes a clean 502.

The upstream address, in one server-only module (lib/bookshelf-api.ts)
import "server-only";

// The only module that knows where the Bookshelf API lives (FastAPI, NestJS or Django)
export const BOOKSHELF_API_URL = process.env.BOOKSHELF_API_URL ?? "http://127.0.0.1:8000";
A BFF proxy with an allow-list (app/api/bff/[…​path]/route.ts)
import { type NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { BOOKSHELF_API_URL } from "@/lib/bookshelf-api";

// Which upstream paths the browser may reach, and with which methods
const ROUTES: { pattern: RegExp; methods: ReadonlySet<string> }[] = [
  { pattern: /^books$/, methods: new Set(["GET", "POST"]) },
  { pattern: /^books\/events$/, methods: new Set(["GET"]) },
  { pattern: /^books\/\d{1,9}$/, methods: new Set(["GET"]) },
  { pattern: /^books\/\d{1,9}\/reviews$/, methods: new Set(["GET"]) },
];

async function proxy(request: NextRequest, ctx: RouteContext<"/api/bff/[...path]">) {
  const { path } = await ctx.params;
  const relative = path.join("/");

  const route = ROUTES.find((r) => r.pattern.test(relative));
  if (!route) return NextResponse.json({ error: "Not found" }, { status: 404 });
  if (!route.methods.has(request.method)) {
    return NextResponse.json(
      { error: "Method not allowed" },
      { status: 405, headers: { Allow: [...route.methods].join(", ") } },
    );
  }

  // The base is fixed; the user only chooses a validated path and a query string.
  // Resolve a *relative* path against a base that ends in "/", so a base with a path
  // prefix (https://api.example/v1) keeps it: .../v1/books, not .../books.
  const base = BOOKSHELF_API_URL.endsWith("/") ? BOOKSHELF_API_URL : `${BOOKSHELF_API_URL}/`;
  const upstreamUrl = new URL(relative, base);
  upstreamUrl.search = request.nextUrl.search;

  // Build the outgoing headers from scratch (allow-list); the token lives in an httpOnly cookie
  const headers = new Headers({ Accept: "application/json" });
  const contentType = request.headers.get("content-type");
  if (contentType) headers.set("Content-Type", contentType);
  const token = (await cookies()).get("bookshelf_token")?.value;
  if (token) headers.set("Authorization", `Bearer ${token}`);

  const upstream = await fetch(upstreamUrl, {
    method: request.method,
    headers,
    body: request.method === "GET" ? undefined : await request.text(),
    redirect: "manual",
    signal: AbortSignal.timeout(5_000),
  }).catch(() => null);
  if (!upstream) return NextResponse.json({ error: "Upstream unavailable" }, { status: 502 });

  // Return only what the browser needs; never copy Set-Cookie or internal headers
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "Content-Type": upstream.headers.get("content-type") ?? "application/json",
      "Cache-Control": "private, no-store",
    },
  });
}

export { proxy as GET, proxy as POST };

The handler was run in front of the Bookshelf stand-in. The allowed reads pass; anything outside the list never reaches the backend; a POST without the session cookie is refused by the backend (the stand-in answers 401, as FastAPI, NestJS or DRF would); with a token cookie the handler turns it into a Bearer header. Event streams pass through unbuffered (upstream.body is returned as is):

$ curl -s "localhost:3604/api/bff/books?q=sol"
[{"id":3,"title":"Solaris","author":"Stanislaw Lem"}]

$ curl -si localhost:3604/api/bff/admin/users | sed -n '1p;$p'
HTTP/1.1 404 Not Found
{"error":"Not found"}

$ curl -si "localhost:3604/api/bff/books/..%2Fadmin%2Fusers" | sed -n '1p;$p'
HTTP/1.1 404 Not Found
{"error":"Not found"}

$ curl -si -X POST localhost:3604/api/bff/books/2 | sed -n '1p;$p'
HTTP/1.1 405 Method Not Allowed
{"error":"Method not allowed"}

$ curl -si -X POST localhost:3604/api/bff/books -H 'content-type: application/json' -d '{"title":"X","author":"Y"}'
HTTP/1.1 401 Unauthorized
{"detail":"Not authenticated"}

$ curl -si -X POST localhost:3604/api/bff/books -H 'Cookie: bookshelf_token=demo-token' \
    -H 'content-type: application/json' -d '{"title":"X","author":"Y"}'
HTTP/1.1 201 Created
{"id":4,"title":"X","author":"Y"}

$ curl -sN localhost:3604/api/bff/books/events
data: {"n":1}      (about 300 ms apart)
data: {"n":2}
data: {"n":3}

What the backend actually received for that last authenticated POST, from the stand-in’s request log, proves the header rule: the request carried the Authorization: Bearer demo-token built on the server, while the browser’s own Authorization: Bearer attacker, the x-evil header and the other cookie were all dropped.

{"method":"POST","path":"/books","headers":{"host":"127.0.0.1:36040","accept":"application/json","content-type":"application/json","authorization":"Bearer demo-token","user-agent":"node","content-length":"26"}}

Two more details of the shape. First, a backend that serves its API under a prefix or insists on a trailing slash, such as Django REST framework with /api/v1/books/, needs that detail in the allow-list pattern and in the upstream path the handler builds (a leading slash in new URL("/books", base) replaces any path in the base URL, so a prefix cannot live in BOOKSHELF_API_URL as written; put it in the path or change the construction). Second, the token in the example cookie is a stand-in: in a real application the session (Auth.js or your own) holds the backend token on the server and the handler reads it from there, as described in the OAuth page above. Authentication of the browser to the BFF must itself be checked in the handler (auth() as in the book handler earlier); do not rely on the Proxy alone.

The books' open-proxy erratum

One of the two requester books builds its API route as a pass-through: it takes the path from the request, interpolates it into the URL of the upstream service and returns whatever comes back. That is an open proxy. The following handler reproduces the mistake, in the scratch app only, to show what it exposes; it is not the book’s listing, only the same idea in the Bookshelf’s terms. Never write this.

Anti-pattern: a catch-all that forwards whatever it is given (scratch only)
import { BOOKSHELF_API_URL } from "@/lib/bookshelf-api";

// ANTI-PATTERN: forwards whatever path and headers the caller sends
export async function GET(request: Request, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const upstream = await fetch(`${BOOKSHELF_API_URL}/${path.join("/")}`, { headers: request.headers });
  return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
}
# an internal-only endpoint the browser was never meant to reach
$ curl -s localhost:3604/api/unsafe/admin/users
[{"id":1,"email":"internal-only@example.test"}]

# the caller's own Authorization header is forwarded as if it were the server's, and the
# upstream Set-Cookie is handed back to the browser
$ curl -si localhost:3604/api/unsafe/books/1 -H 'Authorization: Bearer leaked' | grep -iE "^HTTP|set-cookie"
HTTP/1.1 200 OK
set-cookie: upstream_session=1; Path=/

Everything on the private network that the server can reach through that base URL is now reachable by the whole Internet, with the server’s network position and its credentials. When the interpolated part is the host or a full URL, the same flaw becomes server-side request forgery against cloud metadata endpoints and internal services. The fixes are the five rules above: fix the origin, allow-list the path, restrict the method, build the headers from scratch, rebuild the response. A rewrite (rewrites in next.config.ts) is not a way out for anything needing authorisation, because it forwards by pattern and nothing else; it is for deliberately public pass-through, such as a legacy site, as described in Redirects, Rewrites and Proxy.

Aggregating several calls in one

A BFF earns its keep when one screen needs several backend resources. The handler below fetches the book and its reviews in parallel, computes the average rating and returns only the fields the UI uses, in one round trip. It also validates the identifier before using it in a URL, which is the same lesson as the allow-list in miniature.

One endpoint for the book page (app/api/bff-aggregate/[id]/route.ts)
import { BOOKSHELF_API_URL } from "@/lib/bookshelf-api";

export async function GET(_request: Request, ctx: RouteContext<"/api/bff-aggregate/[id]">) {
  const { id } = await ctx.params;
  if (!/^\d{1,9}$/.test(id)) return Response.json({ error: "Invalid id" }, { status: 400 });

  const [bookRes, reviewsRes] = await Promise.all([
    fetch(`${BOOKSHELF_API_URL}/books/${id}`, { signal: AbortSignal.timeout(5_000) }),
    fetch(`${BOOKSHELF_API_URL}/books/${id}/reviews`, { signal: AbortSignal.timeout(5_000) }),
  ]);
  if (bookRes.status === 404) return Response.json({ error: "Book not found" }, { status: 404 });
  if (!bookRes.ok || !reviewsRes.ok) return Response.json({ error: "Upstream error" }, { status: 502 });

  const book = (await bookRes.json()) as { id: number; title: string; author: string };
  const reviews = (await reviewsRes.json()) as { rating: number }[];
  const average = reviews.length ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length : null;
  // One round trip for the browser, only the fields the UI uses
  return Response.json({ id: book.id, title: book.title, author: book.author, reviewCount: reviews.length, average });
}
$ curl -s localhost:3604/api/bff-aggregate/1
{"id":1,"title":"Dune","author":"Frank Herbert","reviewCount":1,"average":5}

$ curl -si localhost:3604/api/bff-aggregate/99 | sed -n '1p;$p'
HTTP/1.1 404 Not Found
{"error":"Book not found"}

$ curl -si localhost:3604/api/bff-aggregate/1abc | sed -n '1p;$p'
HTTP/1.1 400 Bad Request
{"error":"Invalid id"}

When not to use a Route Handler: your own Server Components

The most common misuse of the BFF is the page that calls its own API. A Server Component runs on the server already: it can call the data layer or the backend directly, with no extra HTTP round trip, no public endpoint and no second authorisation check. Calling fetch('http://localhost:3604/api/books') from a Server Component is slower at request time and breaks the build when the page is prerendered, because during next build there is no server listening. The scratch app reproduced it with a three-line page:

Error occurred prerendering page "/selffetch". Read more: https://nextjs.org/docs/messages/prerender-error
[TypeError: fetch failed] { [cause]: AggregateError: { code: 'ECONNREFUSED' } }
Export encountered an error on /selffetch/page: /selffetch, exiting the build.

Use a Route Handler for clients that are not your Server Components: client-side code that polls or reacts to the browser (geolocation, storage, SWR or TanStack Query), other applications, webhooks. How Server Components fetch is described on the data-fetching page of this section, and the client-side side of the contract in Client-Side Rendering; the React-level view is in Data Fetching. For mutations triggered by your own forms, a Server Action is the better tool (it is a POST that Next.js wires for you, see the Server Actions page of this section); when the same mutation must also serve external clients, put the logic in a function that both the Server Action and the Route Handler call.

Rate limiting, validation and errors

The official guide lists the checks every public handler needs, and each is a few lines in the BFF layer:

  • Rate limiting, in code and with whatever the host offers; answer 429 with Retry-After.

  • Validate everything that arrives: content type, size, shape (a schema library), and sanitise anything used to build markup or URLs.

  • Use timeouts on every upstream call (AbortSignal.timeout) so a slow backend cannot pin your server.

  • Do not leak. Map backend errors to your own small error format; no stack traces, SQL or internal host names. Remove sensitive data from logs.

  • Authenticate and authorise in the handler. A Route Handler is a public URL: use the session or token check from the authentication guide, and have the backend enforce its own permissions as well, because anyone who can reach the backend’s port can bypass the BFF.

Pages Router: API Routes

In a Pages Router application the same job is done by files under pages/api: each file default-exports a function that receives Node’s request and response objects, extended with helpers. They are the predecessor of Route Handlers, and the two are not meant to be mixed for the same path.

A Pages Router API route (pages/api/legacy/books/index.ts)
import type { NextApiRequest, NextApiResponse } from "next";

type Book = { id: number; title: string };
const books: Book[] = [{ id: 1, title: "Dune" }];

export default function handler(req: NextApiRequest, res: NextApiResponse<Book[] | Book | { error: string }>) {
  switch (req.method) {
    case "GET": {
      const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
      return res.status(200).json(books.filter((b) => b.title.toLowerCase().includes(q)));
    }
    case "POST": {
      const title = typeof req.body?.title === "string" ? req.body.title.trim() : "";
      if (!title) return res.status(422).json({ error: "title is required" });
      const book = { id: books.length + 1, title };
      books.push(book);
      return res.status(201).json(book);
    }
    default:
      res.setHeader("Allow", "GET, POST");
      return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
  }
}
$ curl -s localhost:3604/api/legacy/books
[{"id":1,"title":"Dune"}]

$ curl -si -X POST localhost:3604/api/legacy/books -H 'content-type: application/json' -d '{"title":"Hyperion"}'
HTTP/1.1 201 Created
{"id":2,"title":"Hyperion"}

$ curl -si -X DELETE localhost:3604/api/legacy/books
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST
{"error":"Method DELETE Not Allowed"}

A dynamic file such as pages/api/legacy/books/[id].ts receives the segment in req.query as string | string[] | undefined (an array for catch-all names, so narrow it), and cookies in req.cookies. A webhook needs the raw bytes, so the body parser is switched off per route with the exported config, and the body is read from the stream:

A signed webhook in a Pages Router API route (pages/api/legacy/webhook.ts)
import type { NextApiRequest, NextApiResponse } from "next";
import { createHmac, timingSafeEqual } from "node:crypto";

// Turn the automatic body parsing off so the raw bytes can be verified
export const config = { api: { bodyParser: false } };

async function rawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(Buffer.from(chunk));
  return Buffer.concat(chunks);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    return res.status(405).end();
  }
  const body = await rawBody(req);
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "").update(body).digest("hex");
  const received = String(req.headers["x-signature"] ?? "");
  const ok = received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return res.status(400).send("Invalid signature");
  res.status(200).json({ received: JSON.parse(body.toString()).type });
}
$ curl -si -X POST localhost:3604/api/legacy/webhook -H "x-signature: $HMAC_HEX" -d '{"type":"ping"}'
HTTP/1.1 200 OK
{"received":"ping"}

$ curl -si -X POST localhost:3604/api/legacy/webhook -H "x-signature: 00" -d '{"type":"ping"}'
HTTP/1.1 400 Bad Request
Invalid signature

The scratch app runs both routers at once (app/api/…​ and pages/api/legacy/…​, on different paths), which is the migration setup: both file systems coexist, and the route table lists them in separate Route (app) and Route (pages) blocks.

Table 5. Route Handlers versus API Routes in Next.js 16.3
Route Handler (App Router) API Route (Pages Router)

File

app/**/route.ts

pages/api/*/.ts

Handler shape

One export per method (GET, POST, …​)

One default export; branch on req.method

Request and response

Web Request / Response, NextRequest / NextResponse

Node IncomingMessage / ServerResponse with req.query, req.cookies, req.body, res.status().json()

Dynamic segments

await ctx.params (a promise)

req.query

Body parsing

None; call request.json(), text(), formData()

Automatic by Content-Type; bodyParser: false for raw bodies; sizeLimit (1 MB default)

Unsupported method

405 automatically; OPTIONS and HEAD automatic

You write the 405 and the Allow header

Caching

Per model, see Caching GET Handlers: Two Models

No framework cache for the response; set Cache-Control yourself

CORS

You write it (this page)

Same-origin by default; the official example wraps the handler with a CORS helper

Streaming

Native ReadableStream

Supported, but the guide recommends Route Handlers on 14+

Static export (output: 'export')

GET handlers can be exported

Not supported

Edge runtime

runtime = 'edge' deprecated

Not applicable

Migrating an API Route to a Route Handler is mostly mechanical: split the switch into named exports, replace req.query with nextUrl.searchParams or await ctx.params, req.body with await request.json(), and res.status(n).json(x) with Response.json(x, { status: n }). Remove the bodyParser config (there is nothing to turn off). The Pages Router side of redirects, rewrites and headers is in Redirects, Rewrites and Proxy, and Pages-only features generally (including getServerSideProps) in Server-Side Rendering.

Security Checklist for Public Endpoints

Risk What to do in the handler

Anyone can call it

Authenticate and authorise inside the handler (auth() or your token check); never only in the Proxy or in a client hook. Return 401 or 403 before touching data.

Untrusted input

Parse and validate with a schema; limit size; reject unexpected content types; never interpolate input into URLs, queries, shell commands or markup. Use parameterised queries.

Open proxy and SSRF

Fixed upstream origin, allow-listed paths and methods, headers built from scratch (see The Backend for Frontend Pattern).

Forged webhooks

Verify the provider’s signature on the raw body, use constant-time comparison for shared secrets, ignore replays.

Secrets

Read from process.env in server code only; never NEXT_PUBLIC_; never in the repository; never in the response or the logs.

State-changing GET

Use POST, PUT, PATCH or DELETE. A GET can be prefetched, cached, logged, or triggered from an image tag.

Cookie-authenticated mutations (CSRF)

sameSite cookies, an origin check, or a token; Server Actions compare Origin and Host for you, a Route Handler does not.

Cross-origin reads

An explicit CORS allow-list, Vary: Origin, no wildcard with credentials (see CORS).

Open redirects

Compare the destination’s origin with your own before redirect().

Abuse

Rate limit in code and at the host; set timeouts; cap response sizes.

Information leaks

Generic error bodies; no stack traces; do not copy request headers into response headers.

Pitfalls and Error Messages

Symptom Cause and fix

Conflicting route and page at /x

A route.ts and a page.tsx in one folder. Move the endpoint to another segment (for example under api).

Cannot find name 'RouteContext' in tsc

The helper types are generated by next dev, next build or next typegen; run one first (and keep .next/types in tsconfig.json include).

Route segment config "dynamic" is not compatible with nextConfig.cacheComponents

dynamic, revalidate and fetchCache are removed under Cache Components; delete them and use use cache in a helper.

To use "use cache", please enable the feature flag cacheComponents

The directive needs cacheComponents: true. Under the previous model use dynamic = 'force-static', revalidate and fetch options instead.

use cache in the handler body is rejected

Extract it to a helper function and call the helper from the handler.

fetch failed / ECONNREFUSED during next build (prerender-error)

A prerendered Server Component calls your own handler by URL. Call the data function directly.

A GET handler returns stale data in production

It is static: force-static in the previous model, or nothing dynamic touched under Cache Components. Read a runtime API, or set revalidate, or tag it and call revalidateTag.

Webhook signature always fails

The body was parsed before verification (request.json(), a body parser in the Pages Router) or re-encoded. Verify on the raw text and use the exact secret of that endpoint.

Body is unusable: Body has already been read

The stream was read twice. Use request.clone() before the first read.

Preflight succeeds, real call blocked

The actual response lacks Access-Control-Allow-Origin, or the handler answers CORS only on OPTIONS.

Stream arrives all at once

A proxy or compression layer is buffering. Add Cache-Control: no-cache, no-transform, disable buffering at the proxy.

Param is undefined after upgrading from 14

params became a promise in 15: await ctx.params. A codemod exists.

request.ip or request.geo is undefined

Removed in 15.0; read the platform’s forwarding header (for example x-forwarded-for, which a client can spoof unless your proxy overwrites it) or the host’s helper.

What Changed Recently

Table 6. Route Handler and API changes by version
Version Change

13.2

Route Handlers introduced in the App Router, replacing API Routes there.

14 and earlier

GET handlers cached unless they used a dynamic function or option.

15.0 (2024-10-21)

GET handlers are no longer cached by default. context.params is a promise (codemod available). ip and geo removed from NextRequest.

15.1 (2024-12-10)

after() stable, and request APIs such as cookies() and headers() supported inside it for Server Actions and Route Handlers.

15.5 (2025-08-18)

RouteContext (with PageProps and LayoutProps) generated as global types; route export validation.

16.0 (2025-10-21)

Cache Components: GET handlers follow the page model; dynamic, revalidate, fetchCache removed when it is on. middleware deprecated in favour of proxy.ts (Node.js runtime). revalidateTag(tag, profile) takes a profile, and updateTag and refresh are new.

Since 15.0

runtime = 'experimental-edge' was deprecated in the 15.0 release candidate; the route segment config reference now lists runtime = 'edge' and preferredRegion as deprecated too, and the Node.js runtime is the default.

16.3 (2026-08-03)

Root params (next/root-params) are not yet available in Route Handlers (Server Components only); App Shell and instant-navigation features concern pages, not handlers. Cache Components is still opt-in.

Where the Books Are Outdated

The two requester books (Packt 2022, Apress 2023) were written against Next.js 12 and 13, and are used here only as a map of the topics a reader will meet. For this page’s subject they differ from today’s practice in these ways.

Table 7. Book statements versus Next.js 16.3
What an older text does Current practice (16.3)

Builds every backend endpoint as an pages/api API route, because Route Handlers (13.2) did not exist or were not yet the default

Route Handlers under app, with Request and Response; API Routes remain supported in the Pages Router.

Reads req.query and req.body and responds with res.json

NextRequest, await ctx.params, Response.json, NextResponse.

Forwards a path taken from the request to an upstream service from an API route (an open proxy), with a path segment interpolated into the upstream URL without validation

Never. A fixed upstream origin, an allow-listed path and method, headers built on the server, and a rebuilt response.

Treats the redirect to the checkout "success" page as proof of payment

Confirm payment from a signed webhook, verified on the raw body, and fulfil there.

Uses req.headers.origin to build redirect URLs

The header is client-controlled; compare it with an allow-list or use the configured site URL.

Assumes a GET route is cached, or that fetch is cached by default

Neither is true since 15; cache explicitly (previous model) or with use cache (Cache Components).

Uses middleware.ts to guard APIs

proxy.ts (16); and the handler must still check credentials itself.

Keeps secrets in NEXT_PUBLIC_ variables or in the repository

Server-only environment variables in handlers; see Configuration, TypeScript and Environment Variables.

Stripe redirectToCheckout and client-side price calculation

Create the Checkout session on the server from prices read by id on the server, redirect to the session URL, and fulfil from the webhook.

Summary

  • A route.ts exports one function per method over Web Request and Response; OPTIONS and HEAD are automatic, other methods get 405, and it cannot share a folder with a page.tsx.

  • NextRequest and NextResponse add nextUrl, cookies and helpers; validate every body.

  • GET is uncached by default since 15; opt in with force-static and revalidate in the previous model, or let Cache Components prerender it and use use cache in a helper for data.

  • Stream with a ReadableStream; stop on request.signal.

  • Verify webhooks on the raw body, answer fast, be idempotent.

  • CORS is yours to write: allow-list the origin, Vary: Origin, test the preflight.

  • A BFF keeps tokens and backend addresses on the server; the generic forwarder must be allow-listed, never open.

  • Server Components should not call their own handlers; Server Actions are the tool for your own mutations.

  • In the Pages Router the equivalent is pages/api, with Node req and res.