@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
323 lines (252 loc) • 13.8 kB
Markdown
# API routes
> Web-standard endpoints, colocated with your pages, with no configuration.
Server actions and [queries](/guides/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`:
```ts title="src/app/api/health/route.ts"
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
```ts title="src/app/api/echo/route.ts"
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, HEAD
```
`GET`, `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 `Allow` header
naming the ones you did — without which a client cannot tell what would have
worked.
- **`HEAD`.** Answered by your `GET`, with the body stripped, which is what the
spec says `HEAD` is. 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`:
```ts title="src/app/api/greet/[name]/route.ts"
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](/guides/url-validation), `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](/guides/authorization/#protect-an-api-route).
## 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:
```text
○ /api/health
○ /api/pricing
ƒ /api/me
reads the request — headers
ƒ /api/posts/_id_
one url per param value, and none are listed
```
Nothing 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:
```ts
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).searchParams`
reads 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 awaited `searchParams` and 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 `403` with 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](/guides/queries/#live-data)
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.
```ts title="src/app/api/orders/[id]/events/route.ts"
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`](/hosts/deployment#streams-and-idle-timeouts)
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.
```tsx
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:
```tsx
'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:
```tsx
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.
```tsx
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](/guides/queries/) — typed, no endpoint to write |
| your client writes data | a [server action](/guides/server-actions/) |
| 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.
:::note[Stored, or not]
A route that reads nothing per request is answered once at build and served
from the file, like a page — see [the build answers what it
can](#the-build-answers-what-it-can). One that reads the request, or streams,
runs per request. For data that never changes and is read by outsiders, a
[query](/guides/queries/) with `cache: "public"` gets you a cacheable answer
without giving up the type.
:::