Skip to content

Your own backend

The one endpoint a backend in any language answers.

Updated View as Markdown

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:

.envini
RSC_BACKEND=http://127.0.0.1:8080
RSC_HOST_CALL_SECRET=a-long-random-string

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:

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:

app/admin/middleware.tsts
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.

rsc-host.jsonjson
{
  "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: 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. 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.json

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


Reference implementations: Laravel and Go.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close