Skip to content

API routes

Web-standard endpoints, colocated with your pages, with no configuration.

Updated View as Markdown

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:

src/app/api/health/route.tsts
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

src/app/api/echo/route.tsts
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:

src/app/api/greet/[name]/route.tsts
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 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:

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 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.

src/app/api/orders/[id]/events/route.tsts
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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close