Route Handlers
Build HTTP endpoints with route.ts: methods, NextRequest and NextResponse, CORS, signed webhooks, streamed answers, and when a Server Action fits better.
- 01Write an endpointExport one function per HTTP method from route.ts, read the request, and answer with the right status, headers and body.
- 02Open it to the right callersAnswer a browser's CORS preflight, verify a webhook's signature, and stream a long answer piece by piece.
- 03Choose 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.
app/api/teas/route.tsimport { 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}` } });}POST /api/teas201
- Sent with the request
content-typeapplication/json
The body sent with the request
{"id":"jasmine","name":"Jasmine tea","price":5}- Response headers
content-typeapplication/jsonlocation/api/teas/jasminevaryrsc, 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.
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.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:
405for a method the file doesn't export
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:
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 ofapp/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 noPUT, so Next.js answered without running your code.OPTIONS /api/teas:204withallow: GET, HEAD, OPTIONS, POST. The file has noOPTIONSeither, so Next.js answered with the methods the file takes.HEAD, which asks for aGET's headers without its body, comes free withGET.DELETE /api/teas/jasmine:204. ThenGET /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:
app/teas/route.tsexport async function GET() { return Response.json(["Green tea", "Mint tea"]);}$ 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 framesThe 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:
app/api/cart/route.tsimport { 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;}POST /api/cart201
- Sent with the request
content-typeapplication/jsoncookiecart=mint
The body sent with the request
{"tea":"green"}- Response headers
content-typeapplication/jsonset-cookiecart=mint%2Cgreen; Path=/; HttpOnlyvaryrsc, 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:
app/menu.csv/route.tsimport { 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"', }, });}GET /menu.csv200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-dispositionattachment; filename="tea-menu.csv"content-typetext/csv; charset=utf-8
The body that arrived, as it arrived
name,price
Green tea,4
Mint tea,3The 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:
| You want to | Write | Good to know |
|---|---|---|
| Read the address's query | request.nextUrl.searchParams.get("tea") | nextUrl is a URL with Next.js's extras. |
| Read a cookie | request.cookies.get("cart")?.value | Or (await cookies()).get("cart") from next/headers. |
| Read a header | request.headers.get("user-agent") | Or (await headers()).get(…) from next/headers. |
| Read the body | await request.json(), .formData() or .text() | The body can be read once. |
| Answer with JSON | NextResponse.json(data, { status: 201 }) | Response.json does the same; both set content-type. |
| Set a cookie | response.cookies.set("cart", …, { httpOnly: true }) | It becomes a set-cookie header on the answer. |
| Send the visitor elsewhere | NextResponse.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:
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) });}OPTIONS /api/orders204
- Sent with the request
originhttps://leafy.exampleaccess-control-request-methodPOSTaccess-control-request-headerscontent-type,x-partner-key
- Response headers
access-control-allow-headersContent-Type, X-Partner-Keyaccess-control-allow-methodsPOST, OPTIONSaccess-control-allow-originhttps://leafy.examplevaryrsc, 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, answers403, "forbidden", on line 23 when it isn't a partner's, and204with the headers on line 24 when it is.POST, on lines 27 to 34, looks up the key on line 29 and answers401on line 30 unless it belongs to a partner whose site sent the request. Only then does it take the order and answer201with 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"}.
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.
app/api/orders/route.tsNext.jsapp/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) });}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
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.
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.
With CORS headersafter step 1Leafy Café
Order Mint tea
React is running: the page responds to clicks.
The page isn't complete yet.
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.
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.
app/api/webhooks/payments/route.tsimport { 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 });}POST /api/webhooks/payments200
- Sent with the request
content-typeapplication/jsonx-signatured6d1243969ff72efa61c332d304b64c5271f72e999021a16ac5933b0e40b500e
The body sent with the request
{"type":"payment.succeeded","order":7}- Response headers
content-typeapplication/jsonvaryrsc, 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
createHmacfrom Node.js'snode: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.
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).
app/api/brew/route.tsimport 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" } });}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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
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.
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.
app/api/menu/route.tsNext.jsapp/api/menu/route.tsexport async function GET() { return Response.json({ shop: "Tea shop", opens: "9:00" });}$ 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 contentIt 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.
What the five builds printed:
- Reads nothing:
○ /api/menu, prerendered as static content. Both requests got the stored answer, withx-nextjs-cache: HIT. - Reads the database:
ƒ /api/menu, server-rendered on demand. Readingdata/teas.jsonis 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/menuagain, with1hand1din the table's Revalidate and Expire columns, fromcacheLife("hours")."use cache"inGETitself: the build fails while it prerenders/api/menu. A cached function's result has to be plain data, and aResponseisn't. The docs say to put it in a helper.- Reads the request:
ƒ /api/menu. The query only exists when a request comes in.
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:
| Question | Route Handler | Server 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 change | revalidateTag, 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?
- 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.
Sort each job: does it need an address other programs can call, or is it a change from one of your own pages?
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
apifolder." No. Any folder with aroute.tsworks, likeapp/menu.csv/.apiis a habit that keeps them apart from pages. - "A Route Handler without
OPTIONScan't answer a preflight." It answers, with204andallow, 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
201from the version without CORS headers. - "Every
GEThandler runs for each request." No. One that reads nothing uncached and nothing from the request is prerendered:○, withx-nextjs-cache: HIT. - "
"use cache"goes at the top ofGET." 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.redirectis right after a form as it is." Not quite. It answers307unless you pass a status, and a307repeats thePOST. Pass303.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This is app/api/teas/route.ts. The phone app sends PUT /api/teas. What status comes back?
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}` } });
}
405, Method Not Allowed. The file exports only GET and POST, so Next.js answers a PUT itself, with no body. The recorded PUT /api/teas got exactly that.
Same file. Someone sends OPTIONS /api/teas with no CORS headers. What does the answer's allow header list?
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}` } });
}
GET, HEAD, OPTIONS, POST. With no OPTIONS export, Next.js answers 204 and lists the methods the file takes. HEAD comes free with GET.
Which symbol does next build print next to /api/menu for this file?
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);
}
○ /api/menu, prerendered as static content. The file read is cached in getMenu, and nothing comes from the request. The recorded build also lists Revalidate 1h and Expire 1d.
Write the DELETE export of app/api/teas/[id]/route.ts. Which status should it answer, with no body?
import { deleteTea } from "../../../_data/teas";
// Answer DELETE /api/teas/jasmine by deleting that tea.
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 });
}
Lines 10 to 14: DELETE waits for params, deletes the tea, and answers 204, No Content. The recorded DELETE /api/teas/jasmine got 204, and the next GET got 404.
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?
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 });
}
401, with {"error":"Bad signature"}. Line 5 signs the body that arrived, which says 8, and gets a different code from the one in x-signature. The recorded request with order 8 got 401, and line 16 never ran.
This is app/teas/route.ts, next to app/teas/page.tsx, and the build stops. Where can the Route Handler go instead?
export async function GET() {
return Response.json(["Green tea", "Mint tea"]);
}
Move it to its own folder, like app/api/teas/route.ts, which answers /api/teas. app/teas/page.tsx keeps /teas. Each route.ts or page.tsx takes over every method for its address, so one folder can't have both.
Check your understanding
For each question, ask who sends the request, with which method, and what the answer has to carry.
Question 1 of 7Which file answers
GET /api/teas/mint?Choose an answer to see the explanation.
Question 2 of 7This handler answers a plain HTML form. Why does it pass
303toNextResponse.redirect?Read the code, then predictTypeScriptimport { 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.
Question 3 of 7Chrome blocked the partner's order and never sent the
POST. What was missing?Choose an answer to see the explanation.
Question 4 of 7Why does the webhook read the body with
request.text()before it parses it?Read the code, then predictTypeScriptimport { 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.
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.
Question 6 of 7This GET handler puts "use cache" in its own body. What does
next builddo?Read the code, then predictTypeScriptimport { 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.
Question 7 of 7This menu API caches its read in a helper. What does the build's route table say?
Read the code, then predictTypeScriptimport { 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.tsfile answers its folder's address with code. Each export is a method; Next.js answers405for the others, andOPTIONSwith anallowheader. - Handlers get a web
Request(aNextRequest) and return a webResponse. Pick the status:201,204,400,404, and303after a form. - For pages on other origins, answer the preflight in
OPTIONSand 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
401when it doesn't match. - A
ReadableStreambody sends each piece as soon as it's ready, in order. - With Cache Components, a
GEThandler 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.