Server actions and queries cover your own client. An API route is for everyone else: a webhook, an OAuth callback, a health check, a mobile app, a third party.
src/app/api/health/route.ts answers /api/health:
export function GET(request: Request): Response {
return Response.json({ ok: true, at: new Date().toISOString() })
}The export name is the method. The argument is an ordinary Request and a
Response goes back — no event object, no adapter, no handler wrapper.
Nothing to configure. route.ts lives in the same src/app tree as your
pages, and the url comes from the directory the same way a page’s does.
It also runs everywhere the pages do. A route is answered by the same handler that answers a page, not by a separate server, so a Worker, a Node process and a Bun binary all get the identical behaviour from the identical code — verified on Cloudflare Workers, which is the one with no Node apis to fall back on.
One export per method
export async function POST(request: Request): Promise<Response> {
const body = (await request.json()) as { name?: string }
if (!body.name) return Response.json({ error: 'name is required' }, { status: 422 })
return Response.json({ greeting: `Hello, ${body.name}` }, { status: 201 })
}
export function GET(): Response {
return Response.json({ usage: 'POST { "name": "…" }' })
}POST /api/echo {"name":"Ada"} → 201 {"greeting":"Hello, Ada"}
POST /api/echo {} → 422 {"error":"name is required"}
GET /api/echo → 200 {"usage":"POST { \"name\": \"…\" }"}
PUT /api/echo → 405 Allow: GET, POST, HEADGET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Two you do not
have to write:
- The 405. A method you did not export is refused, with an
Allowheader naming the ones you did — without which a client cannot tell what would have worked. HEAD. Answered by yourGET, with the body stripped, which is what the spec saysHEADis. Refusing it breaks link checkers and anything that probes before it fetches.
Reading a body is the web api: request.json(), .formData(), .text().
A route that exports no method at all fails the build rather than becoming a url that answers 405 to everything.
Dynamic segments
[name] and [...rest] work as they do for pages, and bind into the second
argument:
export function GET(request: Request, { params }: { params: { name: string } }): Response {
return Response.json({ greeting: `Hello, ${params.name}` })
}GET /api/greet/ada → {"greeting":"Hello, ada"}It is the same router pages use, so a static segment beats a dynamic one —
/api/user/me is answered by me/route.ts rather than [id]/route.ts,
whichever was declared first.
Guarding one
A route runs whatever middleware.ts files sit above it, exactly as a page in
that directory would — so an endpoint under a guarded path is guarded without
writing the check twice. A refusal is a 401 or 403 rather than a redirect,
because a fetch would follow the redirect and hand back a login page as
though it were your data.
See Authorization.
The build answers what it can
A route is frozen by default, exactly the way a page is, and for the same reason — so there is one model to learn rather than two.
At build time every GET is called once with no request behind it. A route
that answers without reading anything is stored, headers and status and all,
and served from disk afterwards:
○ /api/health
○ /api/pricing
ƒ /api/me
reads the request — headers
ƒ /api/posts/_id_
one url per param value, and none are listedNothing to configure and nothing to opt into. Read the request and the route opts itself out, because with no request to read the accessors never resolve:
export async function GET(request: Request) {
const who = request.headers.get('Authorization') // ƒ — per request
}cookies(), headers() and await connection() do the same, and
connection() is how you say it deliberately when nothing else in the route
happens to give it away.
A route is left to run per request when it:
- reads the request — headers, the body, the signal
- takes a parameter, since the build does not know which values exist
- sits under a
middleware.ts, because a guarded route answers differently per caller and one stored answer served to everyone is how a guard disappears - answers with bytes rather than text, which is a file and wants serving as one
- does not finish, which with no request behind it usually means it was waiting on one
What is never stored
Only GET. A POST, PUT, PATCH or DELETE is something meant to happen,
and an answer kept on disk is an answer to something that already did.
A request carrying a query string runs the route only when the route reads
one. searchParams is awaited rather than handed over resolved, so a route that
never reaches for it provably does not vary by the query — and its stored answer
is served for ?utm_source=anything, which is most of the links people actually
follow. Await it, or export a searchParams schema, and the stored answer is
kept for the bare url alone.
What to use when
| your client reads data | a query — typed, no endpoint to write |
| your client writes data | a server action |
| something that is not your client | an API route |
Reach for an API route when the caller cannot import your functions. If it can, the typed call is better than a url both sides have to agree on.