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 — as a promise, the way a page’s params are. Type the context
from the route it answers and the names come from the segments; the pattern
is checked against the routes the build found, so a typo fails tsc:
import type { RouteContext } from '@rsc-kit/core/route-schema'
export async function GET(request: Request, { params }: RouteContext<'/api/greet/[name]'>) {
const { name } = await params
return Response.json({ greeting: `Hello, ${name}` })
}GET /api/greet/ada → {"greeting":"Hello, ada"}A promise for the same reason a page’s is: a route that never awaits its
query string provably does not vary by it, so the build can store one answer.
It also means params.name without the await is a compile error rather
than a route that quietly answers 404 to everything — which is what a
hand-written { params: { name: string } } used to let through.
RouteHandler<'/api/greet/[name]'> types the whole function when you would
rather write export const GET: RouteHandler<…> = async (request, { params }) => ….
With a params schema, RouteContext<typeof params>
types them from its output instead.
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. To code that fetched the route, a refusal is a
401 or 403 with the destination in X-RSC-Redirect rather than a
redirect, because fetch would follow one and hand back a login page as
though it were your data. To a browser that navigated to the route — followed
a link to it, typed the url — it is a real Location, because a browser
should be sent on, not shown “Unauthorized” over nothing.
A redirect() thrown from the handler itself is different: it is the
route’s answer, not a refusal — the export lives on a signed url now, the old
endpoint moved — and it is a real 3xx with a Location for whoever asked.
notFound() is its 404.
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, and
request.url. The last is the one a Next port trips over:new URL(request.url).searchParamsreads the query where the build cannot see it, and a webhook’s verification handshake stored as a 403 would answer every real one. Read the query from the awaitedsearchParamsand the bare url stays storable. - 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 - sets a cookie, which is an answer for one visitor whatever the route read to decide on it — stored, the build’s cookie would be handed to everyone
- answers 4xx or 5xx to the build, which sent no request to refuse — a
route that says
403with nothing read is checking something the build cannot see, and its refusal is not an answer to keep - 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
And one thing it stores with a warning, the same one a page gets: a route
that froze Date.now() or Math.random() keeps whatever that returned at
build time, and a json body has no browser to move the value to. Read the
request — await connection() is enough — and it runs on demand.
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. Read it off request.url instead and the route
is not stored at all — the table says reads the request — url and what to
await.
A route that streams
A route may answer for as long as the tab is open: server-sent events, from an async generator. Bytes go out only when something changed and arrive at once, which is better per update than polling a query by a wide margin. The costs are a connection held open per tab for as long as it is open — fine on Bun or Node, a limit on a Worker or a small container behind a balancer — no caching, and a source of change to yield from: a database listener, a queue, a timer. A generator that polls the database itself has only moved the polling and kept a connection for the privilege. Start with polling when you have no change feed yet or your host holds few connections; move to events when you do and it does.
import { events, named } from '@rsc-kit/core/events'
export const GET = events(async function* ({ params, signal }) {
const { id } = await params
for await (const status of orderStatus(id, { signal })) {
yield { status } // one message per yield
}
yield named('closed', { id }) // a message with a name
})An ordinary route.ts, so everything above applies: it lives beside its
pages, runs the middleware.ts above it, takes the same params. events()
frames each yield as JSON, sends a keepalive every eight seconds so nothing in
front drops an idle stream (RSC_STREAM_KEEPALIVE_MS
to change it), sets text/event-stream and no-store, no-transform, is never
compressed, and ends the generator when
the browser goes away — that is what signal is for. named(event, data, { id }) names a message or gives it an id, for a client that listens by name or
resumes after a reconnect.
import { useEvents } from '@rsc-kit/core/useEvents'
const { latest, status } = useEvents(`/api/orders/${id}/events`)
// latest: { status: Status } | null - what the route yields
const closed = useEvents(`/api/orders/${id}/events`, { event: 'closed' })
// closed.latest: { id: string } | null - the messages named 'closed'A message is typed from the route. The build records what each events()
route yields, as it records every url, so latest is the generator’s type
and a route that changes what it sends fails the typecheck where the hook
reads it. A url no route answers does not compile. A stream with nothing to
read a type from — another site’s, or a route.ts that builds its own
response — is unknown until you name it: useEvents<Message>(url).
useEvents subscribes in an effect, so it is safe in a component that also
renders on the server. The browser’s EventSource reconnects on its own after
a dropped connection and resumes with Last-Event-ID when you yielded ids.
What it does not survive is an error answer — a 502 while a deploy restarts, a
proxy’s timeout page — after which the browser gives up for good; the hook
opens a new connection, waiting a second, then two, then four, up to thirty,
and at once when the browser comes back online. status says where it is, and
close() stops it.
No library needed. latest is state, and all is the messages so far.
A component that only wants to show the value reads it and is done:
'use client'
import { useEvents } from '@rsc-kit/core/useEvents'
export function OrderStatus({ id, initial }: { id: string; initial: { status: Status } }) {
const { latest, status } = useEvents(`/api/orders/${id}/events`)
const current = latest ?? initial // the server-rendered value until the first message
return (
<p>
{current.status}
{status === 'connecting' && <Spinner />}
</p>
)
}And when the value already lives somewhere — a useState, a reducer, a
cache library — onMessage hands every message to it, so that stays the
source of truth and the stream nudges it:
const [order, setOrder] = useState(initial)
useEvents(`/api/orders/${id}/events`, { onMessage: setOrder }) // useState
useEvents(`/api/orders/${id}/events`, { onMessage: (m) => dispatch({ type: 'update', m }) })
useEvents(`/api/orders/${id}/events`, {
onMessage: (m) => queryClient.setQueryData(['order', id], m), // TanStack
})
useEvents(`/api/orders/${id}/events`, {
onMessage: (m) => mutate(['order', id], m, false), // SWR
})A dropped connection reaches you two ways as well: error is the state, and
onError fires when it happens. The hook reconnects on its own, so this is
for a notice rather than a retry.
useEvents(`/api/orders/${id}/events`, {
onMessage: setOrder,
onError: () => toast('Reconnecting…'),
})A stream is never stored, whatever it reads: it is an answer that keeps going.
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.