UNPKG

@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
# 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. :::