# Your own backend

> The one endpoint a backend in any language answers.

The model is a [BAP — Backend-Answered Pages](/hosts/backend-answered-pages);
this page is the wire.

There is no adapter to write on the JavaScript side. The renderer is what
Nitro builds, on every host, and it already knows how to ask a backend for
things: a server component calls `rpc()`, and the call leaves the process as
an ordinary POST. What a backend implements is that one endpoint. Laravel's is
[about 400 lines of PHP](/hosts/laravel); [Go's](/hosts/go) is about the same.

This page is the contract, so a Rails, Django, .NET or Elixir application can
answer it the same way.

## Telling the renderer where you are

Two variables, and the renderer wires itself. Both in development — `vite`
reads the project's `.env` — and in production, where the built server reads
its process environment:

```ini title=".env"
RSC_BACKEND=http://127.0.0.1:8080
RSC_HOST_CALL_SECRET=… # openssl rand -base64 32 - see Secrets under Deployment
```

`APP_URL` is read where `RSC_BACKEND` is absent, which is what makes a Laravel
app need nothing extra. Optional beside them: `RSC_HOST_CALL_PATH` (default
`/__rsc/host-call`) and `RSC_HOST_GLOBAL` (default `rpc`, the name server
components call). Both, or neither: a secret without a backend has nowhere to
go, and a backend without a secret is refused at the door — the renderer does
not gate on one of the pair alone.

Both are read when the first request arrives, not when the module loads, so
they work wherever the built server runs: a process with an environment, or a
Worker, where Nitro maps the bindings in `wrangler.json` — a `var` for the
address, a `secret` for the secret — onto `process.env` per request. On a
Worker the backend has to be reachable from Cloudflare's network, so it is an
`https` origin behind the secret rather than a loopback address.

Then the plugin's `hostCall` option overrides any of it for a setup that
would rather not use the environment:

```ts
rscKit({ hostCall: { endpoint: 'http://127.0.0.1:8080', secret, path: '/__rsc/host-call' } })
```

## The request

```http
POST /__rsc/host-call
Content-Type: application/json
X-Rsc-Host-Secret: a-long-random-string
Cookie: <the visitor's, forwarded unchanged>
Authorization: <likewise, if the page request had one>

{ "function": "Orders.recent", "args": [5] }
```

`function` is whatever the component passed to `rpc()`; the naming scheme is
yours. `args` is positional, exactly as passed. `Cookie` and `Authorization`
are the only headers copied from the page request — everything else either
describes this POST or is meaningless to you — and they are what let the call
run *as the visitor*: your session middleware reads the cookie and finds the
same person the page is being rendered for. During a build there is no
visitor and the headers are absent.

**Arguments that do not fit the function are a failure, not a refusal.**
Too few, or a value your language cannot decode into the parameter's type:
answer 500 with `error`, as Go and Laravel do. The renderer's calls are typed
from your own manifest, so a mismatch is a bug in a caller - most often a
manifest older than the code - and a 500 is what makes it show up in your
logs. Extra arguments may be ignored. Values that decode but break your
rules - an email that is not one - are the input being invalid: a 422 with
`validationErrors`, below.

**Check the secret first, in constant time, and refuse before dispatch.** This
endpoint runs functions by name with none of your routing in front of it;
`hash_equals('', '')` is true in PHP and its equivalents elsewhere, so an
unconfigured secret has to be rejected before the comparison rather than
trusted to it. Better still, do not register the endpoint at all when no
secret is configured — absent, not open.

## The reply

JSON, and the fields keep the outcomes apart so the renderer never reads a
message to tell an invalid form from a broken server:

```json
{ "result": [ … ], "revalidate": ["orders"] }
```

| you want to say | status | fields |
| --- | --- | --- |
| here is the answer | 200 | `result` |
| …and the action made these regions stale | 200 | `result`, `revalidate: ["orders", "page"]` |
| the input is invalid | 422 | `validationErrors: { "email": ["…"], "address.city": ["…"] }`, `error` |
| there is no session | 401 | `unauthenticated: true`, `error` |
| there is one, and still no | 403 | `unauthorized: true`, `error` |
| go somewhere else | **200** | `redirect: "/login"`, and `redirectStatus` when you mean something other than 307 |
| refused, with its own status | that status | `error`, `refusalStatus: 429` (any 4xx or 5xx: a 503 while a dependency is down) |
| the function failed | 500 | `error`, and in development `debug` |

What each becomes on the other side: `validationErrors` reaches the form
that submitted, each message under its input, dot-joined for a nested field
and under `""` for a message about the form itself. `unauthenticated` and
`unauthorized` become the engine's own `ServerAuthenticationError` and
`ServerAuthorizationError`, so a page answers 401 or 403 the way it would
have if a JavaScript guard had thrown them. `redirect` travels the path every
other redirect travels — a real 3xx above a Suspense boundary, a digest below
one. `revalidate` names [sections](/guides/live-data) or `page`, and the answer
to the action carries the re-rendered region with it rather than the browser
being told to ask again.

Two things that are easy to get wrong:

**A redirect is a 200.** An HTTP client follows a 3xx transparently, so a real
one here would send the host call itself to the destination and hand whatever
it found back to the render as the function's result. The redirect's own
status goes in `redirectStatus`; without it the renderer uses 307, which keeps
the method. Send it whenever your framework says something else - Laravel's
redirects are 302, so the Laravel package always sends it.

**Refusing is not failing.** A form filled in wrongly is the ordinary case, and
`validationErrors` is checked before `error` — a reply carrying both is read as
a refusal with fields, not a failure with none. Reserve `error` alone, with a
500, for the thing the visitor did not cause.

**Say where it failed, in development only.** A 500 may carry `debug`: the
thrown type, its message, and frames newest first.

```json
{ "error": "boom", "debug": { "type": "RuntimeException", "message": "boom",
  "trace": ["app/Rsc/Orders.php:42 App\\Rsc\\Orders->recent()", "…"] } }
```

The renderer puts it on the error the render sees, as its `cause` and under
its stack, so a failed `rpc()` points at the line of your backend that failed,
not only at the JavaScript that called it. Send it only when the app is in
development - Laravel's `app.debug`, Go's `CallbackHandler.Debug` - never in
production, where it would name your files to whoever caused the error, and
never with a refusal, which is an answer and not a bug.

## Batches

Calls issued in the same tick of a render — sibling components each awaiting
`rpc()` — arrive as one POST, so a page's parallel reads cost you one request
rather than one each:

```json
{ "calls": [ { "function": "Orders.recent", "args": [5] }, { "function": "Me.profile", "args": [] } ] }
```

Answer **as each call finishes**: `Content-Type: application/x-ndjson`, one
JSON line per call, carrying its `index` in the batch, the `status` it would
have had alone, and the reply — in whatever order the calls complete, flushed
as they do:

```text
{ "index": 1, "status": 401, "unauthenticated": true }
{ "index": 0, "status": 200, "result": [ … ] }
```

That is what keeps a page's boundaries streaming independently after their
reads travelled together: the renderer resolves each call the moment its
line lands, so a component waiting on a fast read paints while a slow
sibling's is still running. The Go module runs the calls concurrently and
writes each as it returns; Laravel runs them in order and flushes after each.
Set `X-Accel-Buffering: no` so a proxy in front does not hold the lines back.

A backend that would rather answer the whole batch at once may: one JSON
object of `replies`, one per call in order, each with its `status`:

```json
{ "replies": [ { "status": 200, "result": [ … ] }, { "status": 401, "unauthenticated": true } ] }
```

The renderer reads either. The saving of the batch is the same; with the
whole-batch form every call in it waits for the slowest.

Either way, run every call and answer every one — a refusal in the second is
that call's answer, not a reason to leave the third out. Each call's
`revalidate` stays with that call. Batches never mix visitors; every call in
one carried the same forwarded headers.

**At most 50 calls.** The renderer never sends more in one batch; a page with
more is split. Refusing a larger one - 413, before running anything - is a
safe limit, and Laravel's.

**Batches are not optional.** The renderer always sends calls from the same
tick together, and an answer that is neither form - whatever its status -
fails every call in the batch with what your backend said. None is sent
again one by one: they may have run, and an action run twice is worse than
one reported as failed. The conformance suite checks batches, so a backend
that passes it takes them.

## Route middleware

A `middleware.ts` beside or above a page may name guards in your vocabulary:

```ts title="app/admin/middleware.ts"
export const middleware = ['auth', 'can:manage-users']
```

Only the names, no default export: the engine's own guards are a
`middleware.ts` *default export*, a function it runs itself, and a file may
carry either or both. (`route.ts` may carry the names too, from before
`middleware.ts` could.)

The renderer does not know what those mean. Before anything at or below that
directory renders — including a page frozen at build time, before the file is
served — it calls the reserved function with the list:

```json
{ "function": "__rsc.middleware", "args": [["auth", "can:manage-users"]] }
```

Answer `{ "result": true }` to let the render go ahead. **Anything else is a
refusal**: `false`, `null`, a string, an object, a 4xx, a connection error.
The engine reads the literal `true` and nothing else, so a guard that aborts,
redirects or simply throws keeps the page from rendering rather than being
read as silence. Refuse with the fields above — `unauthenticated` when there
is no session, `redirect` to send them to sign in, `refusalStatus` for a
throttle's 429 or a 503 during maintenance — and the page answers accordingly,
with the status and the `error` message. Only a deliberate refusal carries a
`refusalStatus`: a crash leaves it out and is answered 500.

Run them in order, outermost first, and stop at the first refusal: an outer
guard saying no means the inner one should never have been asked.

## Saying data changed

A section names the data it shows:

```tsx
export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })
```

and your backend says when that changed, from a webhook or a job as much as
an action. Every open tab showing it refreshes, with nothing polling. Two
things to provide:

**A way to say a name changed**, callable from anywhere in your backend. It
moves the name's version to the larger of one past where it was and the
current time in milliseconds - so a version never comes round again, and an
old name can be deleted at any time - kept somewhere every instance of your
backend shares - a cache, a table - since a webhook lands on whichever
instance the balancer picked. A name never changed is at 0.

**The reserved function `__rsc.changed`**, asked with what a watcher holds and
how long it would wait:

```json
{ "function": "__rsc.changed", "args": [{ "since": { "team:1:repos": 3, "team:1:members": 0 }, "wait": 5000 }] }
```

Answer `{ "result": { "versions": { ... } } }` with every name in `since`
whose version differs now, and nothing else - an empty object when none
does. If you can, hold the call up to `wait` milliseconds for one to move
and answer the moment it does: that is what makes the refresh immediate.
The renderer asks with 5 seconds; hold no longer than `wait`, and never past
30 seconds, where the renderer gives up on any call. A name you have never
seen is at 0, compared like any other: held while the asker also holds 0,
answered the moment it is changed. If
you cannot - PHP-FPM, a worker that must answer and go - answer at once; the
renderer asks again on its interval, a couple of seconds apart. Both are
conformant, and the page is the same.

The renderer asks once per process for every open tab it holds, never once
per tab, so the cost to you is one small call per interval per renderer. A
tab may only watch names its page was rendered with: the renderer signs them,
so no authorization is needed here beyond the secret.

## Server actions

A `"use server"` function the browser can call is a name the renderer forwards
to you. Write `rsc-host.json` at the project root, beside `vite.config.ts`:
`actions` maps the JavaScript name to whatever your side dispatches on, and
`functions` lists every name `rpc()` may call.

```json title="rsc-host.json"
{
  "actions": { "ordersCancel": "Orders.cancel", "profileUpdate": "Profile.update" },
  "functions": ["Orders.cancel", "Orders.recent", "Profile.update"]
}
```

A function can also carry its types, so the app's `rpc()` and the stubs are
typed: `types` maps a name to its positional `params`, how many trailing ones
are `optional`, a variadic `rest`, and its `result`, each a JSON Schema;
`defs` holds the named object types they refer to by `"$ref": "#/defs/Name"`.

```json
"types": { "Orders.recent": { "params": [{ "type": "integer" }], "result": { "type": "array", "items": { "$ref": "#/defs/Order" } } } },
"defs": { "Order": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] } }
```

The build writes `server-actions.generated.ts` in the source directory
exporting each action, so a client component imports `ordersCancel` and
calls it, and types `rpc()`'s first argument with the functions. Have Vite
run the command that writes it, `rscKit({ hostManifest: { command } })`, so
it cannot go stale: a stale one names a method since renamed, and nothing
fails until the browser calls it. Laravel's `rsc:host-manifest` is this
step; a Go registry's `WriteManifest` writes the same file.

## Urls you own

The renderer forwards any url the route tree does not own — `/login`, a
webhook, an uploaded file — to `RSC_BACKEND`, with `X-Forwarded-Host` and
`X-Forwarded-Proto` set and the header `x-rsc-renderer-fallback: 1`. Trust
the renderer as a proxy so your absolute urls come out against the public
origin.

If your application also proxies to the renderer — sitting in front of it,
the way Laravel does with `RSC_RENDERER_URL` — two things keep a url neither
side owns from bouncing between you forever:

- Set `x-rsc-proxied-by-backend: 1` on what you forward. The renderer answers
  404 itself instead of handing it back.
- When a request arrives carrying `x-rsc-renderer-fallback`, answer 404 for
  anything you do not route. It has already been through the renderer's table.

Whichever process faces the internet, the host-call endpoint must not:
restrict it at the web server, bind the listener to loopback, or serve it on
a unix socket. The secret is the layer the protocol guarantees; the network
is the one it cannot.

## What the renderer expects of you

- **Answer within 30 seconds.** A render blocked on a host that never answers
  is a hung request, and the renderer gives up at 30s (`timeoutMs` on
  `httpHostCalls`, for a host that embeds the engine itself).
- **One process is not enough if you proxy.** A server that proxies a page
  to the renderer holds a worker for the whole render, and the render calls
  back to that same server for its data. With one worker, nobody is left to
  answer. Laravel refuses `php artisan serve` for exactly this.
- **A panic is one failed call.** Recover it into a 500 with `error`; the
  other renders in flight should survive it.
- **Serialise what you return as JSON.** Where your manifest gives a
  function a type, what you send must fit it: an empty list is `[]`, never
  `null`; a time is an ISO 8601 string.

## Before you ship: the conformance suite

An adapter is ready when it passes rsc-kit's conformance suite - all of it.
Every bug found at this boundary so far was a difference between how a
backend and the engine read the wire - a nil list sent as `null` where the
type said a list, a timestamp typed `unknown`, a refusal that did not arrive
as the error a page checks for - and each is a case in the suite. The Laravel
and Go adapters run it in CI on every change, against rsc-kit's main.

Register these functions in your backend, written with your adapter's
ordinary API, the way an app would write them:

| Function | Must |
| --- | --- |
| `Conformance.echo` | return its one argument unchanged |
| `Conformance.emptyList` | return an empty list, the way your language says "no rows" |
| `Conformance.time` | return 2026-01-02T03:04:05Z as your language's time value |
| `Conformance.noTime` | return an absent time |
| `Conformance.unauthenticated` | refuse as not signed in |
| `Conformance.unauthorized` | refuse as not allowed |
| `Conformance.notFound` | refuse as not found (404) |
| `Conformance.refuse` | refuse with 429 and "Slow down." |
| `Conformance.invalid` | refuse the input, on the field `name` |
| `Conformance.invalidNested` | refuse the input, on `address.city` and on the form itself, under `""` |
| `Conformance.redirect` | send the visitor to `/login` |
| `Conformance.revalidate` | mark `orders` stale and return `"ok"` |
| `Conformance.fail` | fail with an ordinary error |
| `Conformance.double` | take one integer and return it doubled; anything else fails it |
| `Conformance.authorization` | return the forwarded `Authorization` header |
| `Conformance.cookie` | return the forwarded `Cookie` header |
| `Conformance.login` | set a cookie named `conformance_login` on the response, and return `"ok"` |
| `Conformance.change` | say `conformance:changed` changed, as your webhook would, and return `"ok"` |

And three guards: `conformance-allow`, which passes, `conformance-deny`, which
refuses, and `conformance-redirect`, which sends the visitor to
`/conformance-login`. Then serve them and run:

```sh
npx -y -p @rsc-kit/core rsc-kit-conformance \
  --endpoint http://127.0.0.1:8123/__rsc/host-call --secret test --manifest rsc-host.json
```

It checks the whole contract on this page:

- **values** - every JSON shape unchanged, `[]` for no rows, ISO 8601 times,
  arguments decoded to their types, and every value against the type your
  manifest declares;
- **outcomes** - each refusal as the engine's own error, field errors with
  dotted paths and the form's under `""`, redirects, revalidation staying
  with its call, a crash staying a failure, and arguments that do not fit
  failing rather than refusing;
- **the request** - `Cookie` and `Authorization` forwarded, a cookie set by a
  call reaching the page, no secret or the wrong one refused with 403, an
  unknown function 404, a malformed body 400;
- **batches** - every call answered, a refusal only its own, and more than 50
  refused with 413 before any runs;
- **guards** - in the order named, stopping at the first that does not pass,
  a redirect from one, and a name nobody registered refusing;
- **live data** - a name never changed at 0, a change moving it to a time in
  milliseconds that never comes round again, only the names that differ
  answered, and a held call bounded by `wait`.

It exits non-zero on any failure, so it can gate your CI. The Go adapter's
`cmd/conformance` and Laravel's `tests/Conformance` are working fixtures to
copy.

## Proposing an adapter

rsc-kit standardises on adapters: a backend in a language rsc-kit has none
for gets one, rather than a second way to reach it. To propose one, open a
discussion or a pull request on [rsc-kit/rsc-kit](https://github.com/rsc-kit/rsc-kit).
To be adopted, an adapter:

- passes the whole conformance suite, in its own CI, against rsc-kit's main
  on every change - not a subset, and not with cases skipped;
- registers the fixtures with its ordinary API, the way an app would;
- covers everything on this page, live data included - `__rsc.changed`, a
  way to say a name changed, and a store every instance shares.

## Testing it without the renderer

The contract is plain HTTP, so a backend's own test suite can cover it
without a JavaScript process: POST the request shape, assert the reply
shape. Laravel's Pest suite does this; the Go adapter's `go test` does the
same. The end-to-end proof — a real page rendered with data from your
process — lives with the engine, which is where a rendering regression can be
caught.

---

Reference implementations: [Laravel](/hosts/laravel) and [Go](/hosts/go).
