The model is a BAP — 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; Go’s 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:
RSC_BACKEND=http://127.0.0.1:8080
RSC_HOST_CALL_SECRET=a-long-random-stringAPP_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:
rscKit({ hostCall: { endpoint: 'http://127.0.0.1:8080', secret, path: '/__rsc/host-call' } })The request
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.
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:
{ "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", optionally redirectStatus (default 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 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.
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.
{ "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:
{ "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:
{ "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:
{ "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. A backend that has not implemented
batches at all loses nothing but the saving: the renderer reads its “no
function name” answer as “no batches here” and sends single calls from then
on. Batches never mix visitors; every call in one carried the same forwarded
headers.
Route middleware
A middleware.ts beside or above a page may name guards in your vocabulary:
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:
{ "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:
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: a number, 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:
{ "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. 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.
{
"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".
"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: 1on 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 (
timeoutMsonhttpHostCalls, 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 servefor 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
[], nevernull; 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. 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.redirect |
send the visitor to /login |
Conformance.revalidate |
mark orders stale and return "ok" |
Conformance.fail |
fail with an ordinary error |
Conformance.authorization |
return the forwarded Authorization header |
And two guards: conformance-allow, which passes, and conformance-deny,
which refuses. Then serve them and run:
npx -y -p @rsc-kit/core rsc-kit-conformance \
--endpoint http://127.0.0.1:8123/__rsc/host-call --secret test --manifest rsc-host.jsonIt checks every function, a batch with a refusal in it, a guard nobody
registered (it must refuse), a call with the wrong secret, and every value
against the type your manifest declares. 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.
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.