cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Route Handlers

Build HTTP endpoints with route.ts: methods, NextRequest and NextResponse, CORS, signed webhooks, streamed answers, and when a Server Action fits better.

By the end, you can
  • 01
    Write an endpointExport one function per HTTP method from route.ts, read the request, and answer with the right status, headers and body.
  • 02
    Open it to the right callersAnswer a browser's CORS preflight, verify a webhook's signature, and stream a long answer piece by piece.
  • 03
    Choose the right toolTell when a GET handler is prerendered, and when a Route Handler fits better than a Server Action.

The tea shop's own API

The tea shop's pages are for people. Other programs want its data too: the shop's phone app lists the teas, a partner café's site sends orders, and the payment provider reports payments. None of them wants HTML. They send an HTTP request to an address and expect data back, usually JSON. A set of such addresses is an API, and each address is an endpoint.

In Next.js, an endpoint is a Route Handler: a file named route.ts that answers requests with code instead of a page. Fetching in the browser wrote a small one for a Client Component. Here is the shop's tea API, with two of them. Like every example here, it's a real Next.js 16.4.3 project: built with next build, run with next start, and sent requests the way another program would, with what came back recorded.

The tea API: two route.ts filesNext.js
app/_data/api/teas/[id]/data/
app/api/teas/route.ts
import { getTeas, saveTea } from "../../_data/teas"; export async function GET() {  const teas = await getTeas();  return Response.json(teas);} export async function POST(request: Request) {  const tea = await request.json();  if (!tea.id || !tea.name || typeof tea.price !== "number") {    return Response.json({ error: "A tea needs an id, a name and a price" }, { status: 400 });  }  await saveTea(tea);  return Response.json(tea, { status: 201, headers: { location: `/api/teas/${tea.id}` } });}
Recorded from Next.js 16.4.3

POST /api/teas201

Sent with the request
content-type application/json

The body sent with the request

{"id":"jasmine","name":"Jasmine tea","price":5}
Response headers
content-type application/json
location /api/teas/jasmine
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

The body that arrived, as it arrived

{"id":"jasmine","name":"Jasmine tea","price":5}

app/api/teas/route.ts answers the address /api/teas: the folders give the address, as they do for a page. It exports two functions, named after HTTP methods. A method is the verb at the start of every request: GET asks for something, POST sends something new. GET, on lines 3 to 6, reads the teas on line 4 and answers with them on line 5. POST, on lines 8 to 15, reads the new tea from the request's JSON body on line 9, saves it on line 13, and answers on line 14.

The Network tab is open on POST /api/teas, Jasmine tea. The recorder sent {"id":"jasmine","name":"Jasmine tea","price":5} with content-type: application/json. The answer is 201, the status for "created", with a location header pointing at the new tea, /api/teas/jasmine, and the tea as JSON. Pick /api/teas, the first request: 200 and the list, with content-type: application/json, which Response.json on line 5 set.

Anyone who knows the address can send these requests, not only the phone app. A real API checks who's calling before it saves or deletes anything, as the partner orders and the webhook below do. These two files leave that out to stay short, so treat them as examples, not as an API to put online.

Four requests on the left: GET, POST, PUT and OPTIONS to /api/teas. GET and POST go to the functions route.ts exports, GET and POST, which answer 200 with the list and 201 with Jasmine tea. PUT and OPTIONS go to Next.js itself, which answers 405 for PUT and 204 with an allow header for OPTIONS.GET /api/teas200: the listPOST /api/teas201: Jasmine teaPUT /api/teas405: no PUT exportOPTIONS /api/teas204, with allowroute.tsGET()POST()Next.js
app/api/teas/route.ts exports GET and POST, and each answers its own method. For a method it doesn't export, Next.js answers: 405 for PUT, and for OPTIONS a 204 whose allow header lists GET, HEAD, OPTIONS, POST.
Real-life analogyThe shop's back door

Customers come in through the front door and look around. Suppliers and couriers use the back door. They don't want the shop floor; they say what they came for, "a delivery" or "a collection", and get a box or a signature back. If they ask for something the shop doesn't do there, someone tells them so.

In real life: The front door, where customers browse
In Next.js: Pages: page.tsx, answered with HTML
In real life: The back door, where suppliers and couriers knock
In Next.js: Route Handlers: route.ts, answered with data
In real life: "Deliveries" or "Collections" on the knock
In Next.js: The method: POST, GET
In real life: A sign: "We don't take returns here"
In Next.js: 405 for a method the file doesn't export
The main idea

A Route Handler is a route.ts file that answers HTTP requests at its folder's address with code. Each function it exports is named after the method it answers, gets the request, and returns a web Response: JSON, a file, a stream or a redirect. It's public, like a page: anyone who knows the address can call it.

route.ts and the HTTP methods

A route.ts file can export GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Open the example's other file, app/api/teas/[id]/route.ts. Its folder [id] is a dynamic segment, so it answers /api/teas/jasmine, /api/teas/mint and every other tea:

app/api/teas/[id]/route.ts: one teaTypeScript
import { deleteTea, getTea } from "../../../_data/teas";

export async function GET(request: Request, { params }: RouteContext<"/api/teas/[id]">) {
  const { id } = await params;
  const tea = await getTea(id);
  if (!tea) return Response.json({ error: `No tea called ${id}` }, { status: 404 });
  return Response.json(tea);
}

export async function DELETE(request: Request, { params }: RouteContext<"/api/teas/[id]">) {
  const { id } = await params;
  await deleteTea(id);
  return new Response(null, { status: 204 });
}

Each function gets two arguments: the request, and a second one whose params holds the segment's value. Like a page's params, it's a promise, so line 4 waits for it. RouteContext<"/api/teas/[id]"> on line 3 is a type Next.js writes for you during the build, so id is known to be a string. Line 6 answers 404, "not found", when there's no such tea. DELETE, on lines 10 to 14, deletes it and answers 204, "done, and nothing to send", with no body.

Back in the Network tab of the tea API, the recorder's requests show each status:

  • POST /api/teas, a tea with no price: 400, "bad request", from lines 10 to 12 of app/api/teas/route.ts, with {"error":"A tea needs an id, a name and a price"}. Check what arrives: anyone can send anything.
  • PUT /api/teas: 405, "method not allowed", with no body. The file exports no PUT, so Next.js answered without running your code.
  • OPTIONS /api/teas: 204 with allow: GET, HEAD, OPTIONS, POST. The file has no OPTIONS either, so Next.js answered with the methods the file takes. HEAD, which asks for a GET's headers without its body, comes free with GET.
  • DELETE /api/teas/jasmine: 204. Then GET /api/teas/jasmine, after deleting: 404, with {"error":"No tea called jasmine"}.

The Terminal tab lists ƒ /api/teas and ƒ /api/teas/[id]: server-rendered on demand, run for each request. The caching section below shows why. One rule about folders: a route.ts and a page.tsx can't share one. Each takes over every method for its address, so a folder with both has two owners:

A page and a Route Handler in one folder (an error on purpose)Next.js
app/teas/
app/teas/route.ts
export async function GET() {  return Response.json(["Green tea", "Mint tea"]);}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./appError: An issue occurred while preparing your Next.js appConflicting route and page at /teas: route at /teas/route and page at /teas/page     at ignore-listed frames

The build stops with Conflicting route and page at /teas: route at /teas/route and page at /teas/page. That's why endpoints usually live under app/api/: Next.js doesn't require the name api, but it keeps them apart from pages.

NextRequest and NextResponse

The request a handler gets is a web Request, the same object fetch uses, and it may return any web Response. Next.js extends both. NextRequest adds nextUrl, the address as a URL object, and cookies. NextResponse adds helpers for JSON, cookies and redirects. The shop's cart, kept in a cookie, uses them:

A cart in a cookie, and a checkout form that redirectsNext.js
app/api/cart/checkout/
app/api/cart/route.ts
import { NextResponse, type NextRequest } from "next/server"; export async function GET(request: NextRequest) {  const cart = request.cookies.get("cart")?.value;  return NextResponse.json({ teas: cart ? cart.split(",") : [] });} export async function POST(request: NextRequest) {  const { tea } = await request.json();  const cart = request.cookies.get("cart")?.value;  const teas = cart ? `${cart},${tea}` : tea;  const response = NextResponse.json({ teas: teas.split(",") }, { status: 201 });  response.cookies.set("cart", teas, { httpOnly: true, path: "/" });  return response;}
Recorded from Next.js 16.4.3

POST /api/cart201

Sent with the request
content-type application/json
cookie cart=mint

The body sent with the request

{"tea":"green"}
Response headers
content-type application/json
set-cookie cart=mint%2Cgreen; Path=/; HttpOnly
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

The body that arrived, as it arrived

{"teas":["mint","green"]}

In app/api/cart/route.ts, line 1 imports both. GET reads the cookie on line 4 with request.cookies.get("cart") and answers with NextResponse.json on line 5. POST reads the tea on line 9 and the cookie on line 10, adds the tea on line 11, makes the answer on line 12 with status 201, and sets the cookie on that answer on line 13. httpOnly keeps the page's own JavaScript from reading it.

The Network tab is open on POST /api/cart, add Green tea, with the cookie. It was sent with cookie: cart=mint, and answered {"teas":["mint","green"]} and set-cookie: cart=mint%2Cgreen; Path=/; HttpOnly. (%2C is a comma, written the way a cookie must carry it.) Pick /api/cart, with the cookie: request.cookies read it back as two teas.

Now open app/api/checkout/route.ts. A plain HTML form on another page posts the order here. Line 4 reads the form's fields with request.formData(), line 5 prints the tea and a header, and line 6 sends the visitor to /thanks. NextResponse.redirect needs a whole address, so line 6 builds one from request.url, the address the request came to. Pick POST /api/checkout: 303, and a location that ends in /thanks. The Terminal tab shows line 5's Order for mint from Mozilla/5.0.

Why pass 303? Without it, NextResponse.redirect answers 307: the recorded version without line 6's 303 got exactly that. A 307 tells the browser to repeat the same request at the new address, so the form would be posted to /thanks again. A 303 tells it to follow with a GET. Redirects explains the codes.

A handler can answer with a file just as well. The staff download the menu as a spreadsheet file:

The menu as a file to downloadNext.js
app/_data/menu.csv/data/
app/menu.csv/route.ts
import { getTeas } from "../_data/teas"; export async function GET() {  const teas = await getTeas();  const rows = teas.map((tea) => `${tea.name},${tea.price}`);  return new Response(["name,price", ...rows].join("\n"), {    headers: {      "content-type": "text/csv; charset=utf-8",      "content-disposition": 'attachment; filename="tea-menu.csv"',    },  });}
Recorded from Next.js 16.4.3

GET /menu.csv200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-disposition attachment; filename="tea-menu.csv"
content-type text/csv; charset=utf-8

The body that arrived, as it arrived

name,price
Green tea,4
Mint tea,3

The folder is named menu.csv, so the address is /menu.csv. Line 6 builds the text, one tea per line, and line 8 says it's CSV, a table as plain text. Line 9, content-disposition: attachment, tells the browser to save it as tea-menu.csv instead of showing it. Each row is one thing a handler needs, and the part that does it:

What NextRequest and NextResponse give you
You want toWriteGood to know
Read the address's queryrequest.nextUrl.searchParams.get("tea")nextUrl is a URL with Next.js's extras.
Read a cookierequest.cookies.get("cart")?.valueOr (await cookies()).get("cart") from next/headers.
Read a headerrequest.headers.get("user-agent")Or (await headers()).get(…) from next/headers.
Read the bodyawait request.json(), .formData() or .text()The body can be read once.
Answer with JSONNextResponse.json(data, { status: 201 })Response.json does the same; both set content-type.
Set a cookieresponse.cookies.set("cart", …, { httpOnly: true })It becomes a set-cookie header on the answer.
Send the visitor elsewhereNextResponse.redirect(new URL("/thanks", request.url), 303)It needs a whole address, so build one from request.url.

Letting other sites call it: CORS

A partner café, Leafy Café, wants an Order Mint tea button on its own site, calling the shop's API. Browsers guard against that by default. A page's JavaScript may read answers only from its own origin: the scheme, host and port of its address, like https://leafy.example. To read an answer from another origin, that origin's server has to say it's allowed, in headers. That's CORS, cross-origin resource sharing (CORS in the JavaScript course explains the rules).

For a request like this one, a POST with a JSON body and a header of its own, the browser asks first. It sends a preflight: an OPTIONS request to the same address, saying which origin, method and headers it wants to use. Only if the answer allows them does it send the real request. CORS only decides which pages may read the answers, so the endpoint also checks who is ordering: each partner has a key, sent in an X-Partner-Key header. Here is the orders endpoint, with a stand-in for the partner's page:

An orders endpoint for partners' pagesNext.js
app/api/orders/partner/
app/api/orders/route.ts
// Each partner's key, and the sites its pages may send orders from. 127.0.0.1 is// Leafy Café's page in the recording, whose port changes, so ports aren't compared.const partners = new Map([["leafy-7f3a", ["https://leafy.example", "http://127.0.0.1"]]]); function site(origin: string) {  if (!URL.canParse(origin)) return "";  const { protocol, hostname } = new URL(origin);  return `${protocol}//${hostname}`;} function cors(origin: string) {  return {    "Access-Control-Allow-Origin": origin,    "Access-Control-Allow-Methods": "POST, OPTIONS",    "Access-Control-Allow-Headers": "Content-Type, X-Partner-Key",    Vary: "Origin",  };} export async function OPTIONS(request: Request) {  const origin = request.headers.get("origin") ?? "";  console.log("Preflight from", origin);  if (![...partners.values()].flat().includes(site(origin))) return new Response(null, { status: 403 });  return new Response(null, { status: 204, headers: cors(origin) });} export async function POST(request: Request) {  const origin = request.headers.get("origin") ?? "";  const sites = partners.get(request.headers.get("x-partner-key") ?? "");  if (!sites?.includes(site(origin))) return Response.json({ error: "Unknown partner" }, { status: 401 });  const order = await request.json();  console.log("Order from", origin, "for", order.tea);  return Response.json({ received: order.tea }, { status: 201, headers: cors(origin) });}
Recorded from Next.js 16.4.3

OPTIONS /api/orders204

Sent with the request
origin https://leafy.example
access-control-request-method POST
access-control-request-headers content-type,x-partner-key
Response headers
access-control-allow-headers Content-Type, X-Partner-Key
access-control-allow-methods POST, OPTIONS
access-control-allow-origin https://leafy.example
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Origin

No body arrived: this answer is only its status and headers

In app/api/orders/route.ts:

  • Line 3 lists the partners: Leafy Café's key, and the sites its pages come from. site, on lines 5 to 9, turns an origin into its scheme and host, because the recording's stand-in page runs on a port that changes; a real list would hold whole origins.
  • cors, on lines 11 to 18, makes the headers: line 13 allows the page's own origin, line 14 the methods, and line 15 the two headers the page sends. Line 16, Vary: Origin, tells caches the answer depends on the origin.
  • OPTIONS, on lines 20 to 25, prints the origin on line 22, answers 403, "forbidden", on line 23 when it isn't a partner's, and 204 with the headers on line 24 when it is.
  • POST, on lines 27 to 34, looks up the key on line 29 and answers 401 on line 30 unless it belongs to a partner whose site sent the request. Only then does it take the order and answer 201 with the headers, on line 33.

The Network tab is open on OPTIONS /api/orders. The recorder sent the same preflight a browser sends: origin: https://leafy.example, access-control-request-method: POST and access-control-request-headers: content-type,x-partner-key. The answer is 204 with access-control-allow-origin: https://leafy.example and the other two. The same preflight from https://stranger.example got 403. Then POST /api/orders with Leafy Café's key: 201 and {"received":"mint"}. Without the key: 401, {"error":"Unknown partner"}.

Left, the partner's page in Chrome, at origin 127.0.0.1. Right, the shop's server, at localhost. Four arrows in order: OPTIONS /api/orders to the shop, 204 with access-control-allow-origin naming the page's origin back, POST /api/orders with the partner's key to the shop, 201 with access-control-allow-origin back. The page then shows The shop answered 201: mint.127.0.0.1: the partnerpartnerOrder Mint teaanswered 201: mintlocalhost: the shopshopOPTIONS()POST()OPTIONS /api/orders204 + allow-originPOST /api/orders201 + allow-origin
A POST with JSON to another origin takes two round trips. Chrome first asks with a preflight, an OPTIONS request. Only when the answer allows the page's origin does it send the order, and only when that answer allows it too does the page get to read it.

A real browser did it too. The page in app/partner/ stands in for Leafy Café's site. The recorder opened it in Chrome at http://127.0.0.1:3000, and line 10 of app/partner/order-button.tsx calls the shop at http://localhost:3000: the same computer, but to the browser another origin. Line 15 sends the key. It's in the page, so anyone can read it: it names the partner, and it isn't a secret. A secret key belongs only in calls from one server to another. Try both versions of the route below. Before you click, guess what the page shows without the CORS headers.

Playground: can the partner's page read the answer?
The shop and the partner's page, with this version of app/api/orders/route.tsNext.js
app/api/orders/partner/
app/api/orders/route.ts
// Each partner's key, and the sites its pages may send orders from. 127.0.0.1 is// Leafy Café's page in the recording, whose port changes, so ports aren't compared.const partners = new Map([["leafy-7f3a", ["https://leafy.example", "http://127.0.0.1"]]]); function site(origin: string) {  if (!URL.canParse(origin)) return "";  const { protocol, hostname } = new URL(origin);  return `${protocol}//${hostname}`;} function cors(origin: string) {  return {    "Access-Control-Allow-Origin": origin,    "Access-Control-Allow-Methods": "POST, OPTIONS",    "Access-Control-Allow-Headers": "Content-Type, X-Partner-Key",    Vary: "Origin",  };} export async function OPTIONS(request: Request) {  const origin = request.headers.get("origin") ?? "";  console.log("Preflight from", origin);  if (![...partners.values()].flat().includes(site(origin))) return new Response(null, { status: 403 });  return new Response(null, { status: 204, headers: cors(origin) });} export async function POST(request: Request) {  const origin = request.headers.get("origin") ?? "";  const sites = partners.get(request.headers.get("x-partner-key") ?? "");  if (!sites?.includes(site(origin))) return Response.json({ error: "Unknown partner" }, { status: 401 });  const order = await request.json();  console.log("Order from", origin, "for", order.tea);  return Response.json({ received: order.tea }, { status: 201, headers: cors(origin) });}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/partnerTea shop
Leafy Café
Order Mint tea

The shop answered 201: mint

Focus
the button “Order Mint tea”
Scrolled
0 px down

No requests during this step: the page already had what it needed

Try it yourself
With CORS headers

OPTIONS answers the preflight with the CORS headers. Chrome sends the POST, and the page shows The shop answered 201: mint. The Terminal shows both requests arriving from http://127.0.0.1:3000.

Both versions were built with Next.js 16.4.3 and clicked in real Chrome, which opened the partner's page at 127.0.0.1 and called the shop at localhost. Only app/api/orders/route.ts changes. Reset goes back to the first version.

With the headers, the page shows The shop answered 201: mint, and the Terminal tab shows what reached the server: Preflight from http://127.0.0.1:3000, then Order from http://127.0.0.1:3000 for mint. Chrome sent the preflight on its own; the page's code never saw it. Without the headers, Next.js answered the preflight itself, and Chrome logged has been blocked by CORS policy and never sent the order: the Terminal shows only the recorder's own order from https://leafy.example. A program that isn't a browser isn't stopped by CORS at all, which is why the key check is in POST: CORS protects visitors' browsers, and the endpoint still decides who may order.

Step through both below.

Playground: one cross-origin order, step by step
Who does whatWith CORS headers
What the visitor seesafter step 1
127.0.0.1:3000/partner

Leafy Café

Order Mint tea

React is running: the page responds to clicks.

The page isn't complete yet.

Step 1 of 6
Browser: The visitor clicks Order Mint tea

The page is at http://127.0.0.1:3000 and calls http://localhost:3000/api/orders: another origin. A POST with JSON is a request the browser won't send to another origin without asking first.

Each step is one thing that happens, in order. The first and last screens are what Chrome showed in the recordings of the two versions; the preflight shows in the shop's log. Reset goes back to the first step with CORS headers.

Two notes. For public data that anyone may read, Access-Control-Allow-Origin: * allows every origin. A request that sends cookies needs three things instead: the exact origin in Access-Control-Allow-Origin (never *), Access-Control-Allow-Credentials: true on the answers, and the page's own fetch sending them, with credentials: "include". And to add the same headers to many routes, the docs suggest proxy.ts or headers in next.config.ts. proxy.ts has a lesson of its own, and the Building real apps stage covers checking access.

Webhooks: checking who sent it

A webhook is a request another service sends to your app when something happens there: a payment went through, a post was published. You give the service an address, a Route Handler, and it calls it. Since the address is public, the handler must check that the request really came from that service. Most services sign each request: they compute a code from the body and a secret only you and they know, and send it in a header.

The payment provider's webhook, with its signature checkedNext.js
app/api/webhooks/payments/
app/api/webhooks/payments/route.ts
import { createHmac, timingSafeEqual } from "node:crypto"; function isSigned(body: string, signature: string | null) {  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;  const secret = process.env.PAYMENTS_WEBHOOK_SECRET!;  const expected = createHmac("sha256", secret).update(body).digest();  const received = Buffer.from(signature, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} export async function POST(request: Request) {  const body = await request.text();  if (!isSigned(body, request.headers.get("x-signature"))) {    return Response.json({ error: "Bad signature" }, { status: 401 });  }  const event = JSON.parse(body);  console.log("Payment event:", event.type, "for order", event.order);  return Response.json({ received: true });}
Recorded from Next.js 16.4.3

POST /api/webhooks/payments200

Sent with the request
content-type application/json
x-signature d6d1243969ff72efa61c332d304b64c5271f72e999021a16ac5933b0e40b500e

The body sent with the request

{"type":"payment.succeeded","order":7}
Response headers
content-type application/json
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

The body that arrived, as it arrived

{"received":true}

The secret lives in .env, a server environment variable; it never goes in code or in a NEXT_PUBLIC_ variable. In app/api/webhooks/payments/route.ts:

  • Line 12 reads the body as text, request.text(), before anything else. The signature was made from those exact characters; parsing the JSON and writing it again could change them.
  • Line 4 rejects anything that isn't a signature at all: it must be 64 hex digits (0 to 9 and a to f), the length of a SHA-256 code written in hex.
  • Line 6 makes the code this body should have: an HMAC, made from the text and the secret with createHmac from Node.js's node:crypto. The same text and secret always give the same code; any other text gives a different one. Line 7 turns the header's hex digits into bytes.
  • Line 8 checks that both hold the same number of bytes, then compares them with timingSafeEqual, which takes the same time however many bytes match, so the time an answer takes gives nothing away. It throws when the lengths differ, which is why the lengths are checked first.
  • Lines 13 to 15 answer 401, "not authorized", when it doesn't match. Only then does line 16 parse the event, and line 17 act on it.

The Network tab shows four requests. signed carries the body {"type":"payment.succeeded","order":7} and its real signature: 200, {"received":true}, and the Terminal shows Payment event: payment.succeeded for order 7. changed on the way has the same signature but order 8 in the body: 401. not signed and not a signature at all, 64 letters z: 401. None of them printed anything.

Left, the payment provider signs the body with the shared secret and sends it as x-signature. An arrow labeled POST with x-signature goes to the shop's route.ts, which signs the body it received with the same secret and compares. If the two match: 200, received. If the body changed or there is no signature: 401, Bad signature.providerthe body+ secret= signaturePOSTwith x-signatureroute.tsthe body it received+ the same secret= the same signature?yes: 200, receivedno: 401a changed body or no signature: 401
Both sides know the secret; nobody else does. The provider signs the body and sends the signature in x-signature. The handler signs the body it received the same way. The same body gives the same signature, so a match means the provider sent it, unchanged.

Real providers add more, like a time in the signed text so an old request can't be sent again, and give you a library that checks it all; use theirs. A webhook from a content service often only says "the menu changed". The handler then calls revalidateTag, as Updating the UI after a mutation showed: updateTag throws in a Route Handler.

Streaming a response

Some answers take a while and come in parts: a long report, an AI model's reply word by word. A handler can send each part as soon as it's ready, by returning a stream as the body: a ReadableStream, the web's object for data that arrives in pieces (Streams and progress covers it).

Brewing steps, streamed one line at a timeNext.js
app/api/brew/brew/
app/api/brew/route.ts
import type { NextRequest } from "next/server"; function wait(ms: number) {  return new Promise((resolve) => setTimeout(resolve, ms));} export async function GET(request: NextRequest) {  const tea = request.nextUrl.searchParams.get("tea") ?? "green";  const steps = [`Heating water for ${tea} tea`, "Steeping for three minutes", "Your tea is ready"];  const encoder = new TextEncoder();  const stream = new ReadableStream({    async start(controller) {      for (const [index, step] of steps.entries()) {        if (index > 0) await wait(1000);        controller.enqueue(encoder.encode(`${step}\n`));      }      controller.close();    },  });  return new Response(stream, { headers: { "content-type": "text/plain; charset=utf-8" } });}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/brewTea shop
Brew a tea
Brew Mint tea
  • Heating water for mint tea
  • Steeping for three minutes

Brewing…

Focus
the button “Brew Mint tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. GET/api/brew?tea=mint200text/plain

In app/api/brew/route.ts, line 8 reads the tea from the query, and line 9 lists three steps. Lines 11 to 19 make the stream. Its start function runs once: for each step it waits a second (line 14, from the second step on) and puts the step into the stream with controller.enqueue on line 15, as bytes from the TextEncoder of line 10, with a \n at the end. Line 17 closes it. Line 20 returns the stream as the body, and the function returns before the stream is done.

app/brew/brew-button.tsx reads it as it comes. Line 11 fetches, and line 12 gets a reader that turns bytes back into text. Each reader.read(), on line 15, gives whatever text has arrived, which isn't always one line: the network may split a piece in two, or deliver two at once. So the loop on lines 14 to 21 adds each read to what's left from the last one, splits it at \n on line 18, keeps the unfinished end on line 19, and shows the whole lines on line 20.

The recorder clicked Brew Mint tea in Chrome and stopped the step as soon as the second line showed. The Steps tab is open on it: Heating water for mint tea and Steeping for three minutes, still Brewing…, and no third line yet: the response was still open. Step 3 waited for Done, with Your tea is ready under the other two. One request, GET /api/brew?tea=mint, and three lines, in order.

A time line from left to right. The handler sends three pieces of one response one after the other: Heating water for mint tea at once, Steeping for three minutes a second later, Your tea is ready a second after that.Heating water for mint teasent at onceSteeping for three minutesa second laterYour tea is readya second after thattime, as the handler sends
One GET /api/brew?tea=mint, one response, three pieces, sent a second apart by the handler's code. The page shows each line as soon as it has arrived whole, so the first lines are on screen while the response is still open.

The Network tab shows the same answer read whole, the way a program that doesn't read pieces gets it: three lines, in the order they were sent. Two things to know: some hosts stop a request that runs too long, and a streamed answer's status and headers go out before its first piece, so they can't depend on what comes later.

Which GET handlers are prerendered

With Cache Components on, as in every new project, the docs say GET handlers follow the same model as pages. They run at request time by default, and Next.js prerenders one during next build when it reads no uncached data and nothing from the request. Other methods never are. The menu API below comes in five versions. Before you click, guess the symbol for each.

Playground: is this GET handler prerendered?
The shop's menu API, with this version of app/api/menu/route.tsNext.js
app/_data/api/menu/data/
app/api/menu/route.ts
export async function GET() {  return Response.json({ shop: "Tea shop", opens: "9:00" });}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /api/menu ○  (Static)  prerendered as static content
Try it yourself
Reads nothing

It returns the same JSON every time and reads nothing, so next build ran it and kept the answer: ○ /api/menu. Both requests get the stored copy, with x-nextjs-cache: HIT.

Every version was built with next build from Next.js 16.4.3; the Terminal shows what it printed, and the Network tab what next start sent back. Only app/api/menu/route.ts changes. Reset goes back to the first version.

What the five builds printed:

  • Reads nothing: ○ /api/menu, prerendered as static content. Both requests got the stored answer, with x-nextjs-cache: HIT.
  • Reads the database: ƒ /api/menu, server-rendered on demand. Reading data/teas.json is uncached work, so the build left the handler for request time. That's why the tea API is ƒ too.
  • "use cache" in a helper: ○ /api/menu again, with 1h and 1d in the table's Revalidate and Expire columns, from cacheLife("hours").
  • "use cache" in GET itself: the build fails while it prerenders /api/menu. A cached function's result has to be plain data, and a Response isn't. The docs say to put it in a helper.
  • Reads the request: ƒ /api/menu. The query only exists when a request comes in.
app/api/menu/route.ts: the read cached in a helperTypeScript
import { cacheLife } from "next/cache";
import { getTeas } from "../../_data/teas";

async function getMenu() {
  "use cache";
  cacheLife("hours");
  return getTeas();
}

export async function GET() {
  const teas = await getMenu();
  return Response.json(teas);
}

Lines 4 to 8 are the helper: "use cache" on line 5, a lifetime on line 6, and the read on line 7. GET, on lines 10 to 13, only calls it and wraps the data in a Response. Older versions worked differently: in Next.js 13 and 14 a GET handler was static unless it read the request, Next.js 15 made them dynamic by default, and without Cache Components you opt in with export const dynamic = "force-static". The Rendering and caching stage covers lifetimes, tags and the older model.

Route Handler or Server Action?

Both run your code on the server when a request comes in, and both are public. They are built for different callers. Each row asks one question of both:

Route Handlers and Server Actions
QuestionRoute HandlerServer Action
Who calls it?Any program that can send an HTTP request: a phone app, another site, a payment provider, curl.Your own pages: a form, a button or an Effect in this app.
How is it called?By its address and method: POST /api/teas.Like a function: addTea(formData). Next.js makes the request for you.
Which methods?GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS.Only POST.
What comes back?Whatever you build: JSON, a file, a stream, a redirect.The action's result, and the page rendered again when it expires data.
Can it be prerendered?A GET handler can, when it reads nothing uncached.No. An action runs when it's called.
Showing your own changerevalidateTag, revalidatePath. updateTag and refresh() throw here.updateTag, revalidatePath, refresh(), redirect().

The short rule: if another program calls it, or the answer isn't a page update (a file, a stream, JSON for an app), write a Route Handler. If your own page changes data and should show the result, write a Server Action. And a Server Component never fetches your own Route Handlers: it calls the data function directly, as Fetching data in Server Components recorded.

Planning the tea shop's endpoints

The shop has eight jobs left. Sort them: which ones need a Route Handler, and which a Server Action?

Route Handler or Server Action?
  • The payment provider tells the shop that order 7 was paid.
  • The shop's phone app loads the list of teas as JSON.
  • The staff download the menu as tea-menu.csv.
  • A partner's site posts orders from its own pages.
  • Asha adds a tea from the staff page's form, and the list must show it at once.
  • A Like button on a tea's page counts a like.
  • A visitor posts a review, and the form must work before JavaScript loads.
  • The staff rename a tea from the admin page and land on the tea's page.
Try it yourself
0 of 8 correct

Sort each job: does it need an address other programs can call, or is it a change from one of your own pages?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Every Route Handler on that list is public. Before it answers with private data or changes anything, it has to check who's asking, as the webhook checks its signature. The Building real apps stage covers sign-in and checking access in every handler.

Common mix-ups

  • "Endpoints have to live in an api folder." No. Any folder with a route.ts works, like app/menu.csv/. api is a habit that keeps them apart from pages.
  • "A Route Handler without OPTIONS can't answer a preflight." It answers, with 204 and allow, but with no CORS headers, so Chrome stops there.
  • "CORS keeps other programs out." No. It tells browsers which pages may read an answer. The recorder, which isn't a browser, got 201 from the version without CORS headers.
  • "Every GET handler runs for each request." No. One that reads nothing uncached and nothing from the request is prerendered: ○, with x-nextjs-cache: HIT.
  • ""use cache" goes at the top of GET." No. The build fails there; put it in a helper that returns plain data.
  • "Parse the webhook's JSON, then check the signature." No. Check the exact text first; the signature was made from it.
  • "NextResponse.redirect is right after a form as it is." Not quite. It answers 307 unless you pass a status, and a 307 repeats the POST. Pass 303.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upA method the file doesn't export

This is app/api/teas/route.ts. The phone app sends PUT /api/teas. What status comes back?

Starter codeTypeScript
import { getTeas, saveTea } from "../../_data/teas";

export async function GET() {
  const teas = await getTeas();
  return Response.json(teas);
}

export async function POST(request: Request) {
  const tea = await request.json();
  if (!tea.id || !tea.name || typeof tea.price !== "number") {
    return Response.json({ error: "A tea needs an id, a name and a price" }, { status: 400 });
  }
  await saveTea(tea);
  return Response.json(tea, { status: 201, headers: { location: `/api/teas/${tea.id}` } });
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the allow header

    Same file. Someone sends OPTIONS /api/teas with no CORS headers. What does the answer's allow header list?

    Starter codeTypeScript
    import { getTeas, saveTea } from "../../_data/teas";
    
    export async function GET() {
      const teas = await getTeas();
      return Response.json(teas);
    }
    
    export async function POST(request: Request) {
      const tea = await request.json();
      if (!tea.id || !tea.name || typeof tea.price !== "number") {
        return Response.json({ error: "A tea needs an id, a name and a price" }, { status: 400 });
      }
      await saveTea(tea);
      return Response.json(tea, { status: 201, headers: { location: `/api/teas/${tea.id}` } });
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePredict the menu's symbol

      Which symbol does next build print next to /api/menu for this file?

      Starter codeTypeScript
      import { cacheLife } from "next/cache";
      import { getTeas } from "../../_data/teas";
      
      async function getMenu() {
        "use cache";
        cacheLife("hours");
        return getTeas();
      }
      
      export async function GET() {
        const teas = await getMenu();
        return Response.json(teas);
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWrite a DELETE handler

        Write the DELETE export of app/api/teas/[id]/route.ts. Which status should it answer, with no body?

        Starter codeTypeScript
        import { deleteTea } from "../../../_data/teas";
        
        // Answer DELETE /api/teas/jasmine by deleting that tea.
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA webhook changed on the way

          Someone catches the provider's signed request for order 7, changes the body to order 8, and sends it with the same x-signature. What does the handler answer, and why?

          Starter codeTypeScript
          import { createHmac, timingSafeEqual } from "node:crypto";
          
          function isSigned(body: string, signature: string | null) {
            if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
            const secret = process.env.PAYMENTS_WEBHOOK_SECRET!;
            const expected = createHmac("sha256", secret).update(body).digest();
            const received = Buffer.from(signature, "hex");
            return received.length === expected.length && timingSafeEqual(received, expected);
          }
          
          export async function POST(request: Request) {
            const body = await request.text();
            if (!isSigned(body, request.headers.get("x-signature"))) {
              return Response.json({ error: "Bad signature" }, { status: 401 });
            }
            const event = JSON.parse(body);
            console.log("Payment event:", event.type, "for order", event.order);
            return Response.json({ received: true });
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFix the build

            This is app/teas/route.ts, next to app/teas/page.tsx, and the build stops. Where can the Route Handler go instead?

            Starter codeTypeScript
            export async function GET() {
              return Response.json(["Green tea", "Mint tea"]);
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who sends the request, with which method, and what the answer has to carry.

              The Route Handlers quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which file answers GET /api/teas/mint?

                Choose an answer to see the explanation.

              2. Question 2 of 7This handler answers a plain HTML form. Why does it pass 303 to NextResponse.redirect?

                Read the code, then predictTypeScript
                import { NextResponse, type NextRequest } from "next/server";
                
                export async function POST(request: NextRequest) {
                  const form = await request.formData();
                  console.log("Order for", form.get("tea"), "from", request.headers.get("user-agent"));
                  return NextResponse.redirect(new URL("/thanks", request.url), 303);
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7Chrome blocked the partner's order and never sent the POST. What was missing?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why does the webhook read the body with request.text() before it parses it?

                Read the code, then predictTypeScript
                import { createHmac, timingSafeEqual } from "node:crypto";
                
                function isSigned(body: string, signature: string | null) {
                  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
                  const secret = process.env.PAYMENTS_WEBHOOK_SECRET!;
                  const expected = createHmac("sha256", secret).update(body).digest();
                  const received = Buffer.from(signature, "hex");
                  return received.length === expected.length && timingSafeEqual(received, expected);
                }
                
                export async function POST(request: Request) {
                  const body = await request.text();
                  if (!isSigned(body, request.headers.get("x-signature"))) {
                    return Response.json({ error: "Bad signature" }, { status: 401 });
                  }
                  const event = JSON.parse(body);
                  console.log("Payment event:", event.type, "for order", event.order);
                  return Response.json({ received: true });
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7The recorder stopped a step on the brew page as soon as the second line showed. What else did the page show?

                Choose an answer to see the explanation.

              6. Question 6 of 7This GET handler puts "use cache" in its own body. What does next build do?

                Read the code, then predictTypeScript
                import { getTeas } from "../../_data/teas";
                
                export async function GET() {
                  "use cache";
                  const teas = await getTeas();
                  return Response.json(teas);
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7This menu API caches its read in a helper. What does the build's route table say?

                Read the code, then predictTypeScript
                import { cacheLife } from "next/cache";
                import { getTeas } from "../../_data/teas";
                
                async function getMenu() {
                  "use cache";
                  cacheLife("hours");
                  return getTeas();
                }
                
                export async function GET() {
                  const teas = await getMenu();
                  return Response.json(teas);
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • A route.ts file answers its folder's address with code. Each export is a method; Next.js answers 405 for the others, and OPTIONS with an allow header.
              • Handlers get a web Request (a NextRequest) and return a web Response. Pick the status: 201, 204, 400, 404, and 303 after a form.
              • For pages on other origins, answer the preflight in OPTIONS and send the CORS headers on every answer. CORS guards browsers, not the endpoint.
              • Check a webhook's signature from the exact body text with a shared secret, and answer 401 when it doesn't match.
              • A ReadableStream body sends each piece as soon as it's ready, in order.
              • With Cache Components, a GET handler that reads nothing uncached and nothing from the request is prerendered; cache reads in a helper with "use cache".
              • Other programs call Route Handlers; your own pages call Server Actions.

              Remember the one-liner.
              A Route Handler is an address with code behind it: one export per method, a Response back, open to anyone who knows it.

              Coming next: How Next.js renders pages, the first lesson of the Rendering and caching stage: static and dynamic per component, build time and request time, and the shell that ties them together.

              CompleteFrontend Clear concepts. Working examples.