An rsc-kit app with a backend behind it — Laravel, Go, anything that answers the contract — is a BAP: Backend-Answered Pages. A page is rendered in front of the backend rather than by it. The renderer asks; the backend answers.
This page is the whole idea, once. The Laravel and Go pages are how each backend answers, and Your own backend is the contract a third one implements.
Beside the shapes you know
| who renders the page | who the backend talks to | how a page gets data | |
|---|---|---|---|
| MPA — Blade, Rails views, Inertia | the backend | the browser | in the controller, before the view |
| SPA — React + an API | the browser | the browser, over an API | fetch('/api/orders') after load |
| BAP — rsc-kit | a renderer, on the server | the renderer, over loopback | await rpc('Orders.recent') inside the component |
If you have built a SPA with an API behind it, you know most of the shape: the React app is what the visitor sees, and the backend is the thing it asks for data. The one change is that the React app is rendered on a server, and that server is the one asking the backend — not the browser. The visitor gets a finished, streamed page; the backend never builds one.
In plain terms: a restaurant. In an MPA you order from the kitchen and the kitchen sends out the whole plate. In a SPA the kitchen hands you an empty plate and a recipe, and your browser cooks at the table, running back for every ingredient. In a BAP there is a chef between you and the kitchen — the renderer. You order a page; the chef plates it right there, asking the kitchen through a hatch for what only the kitchen has: your orders, whether you are signed in, whether you may be in this room. The kitchen only ever answers. And the dishes that never change, the chef plated in advance.
Why
What a BAP gives you that the other two shapes do not:
- No API layer. A page calls a backend function by name; there is no endpoint to design, no serialiser, no client, no versioning of a contract between your own frontend and your own backend. The one endpoint that exists is private and generic.
- The backend keeps everything it is good at. Sessions, auth, policies, validation, queues, mail, migrations — untouched, and reachable from a page in one line. A SPA rebuilds half of that in the browser; a BAP never moves it.
- Nothing ships to the browser for data. Server components render with the data in them. No loading spinners for the first paint, no fetch waterfall, no client bundle for the parts that only display.
- Streaming and prerendering, on top of a backend. The shell paints before the slow query answers; pages that need nothing per visitor are frozen at build time and served as files, and partial prerendering does both on one page — none of which an MPA can do, and none of which a SPA gets without a second server.
- The backend is never reached from the browser. The host-call endpoint is private to the renderer, behind a secret. The attack surface is one loopback route rather than an API a visitor can enumerate.
- A worker per call, not per page. The backend answers short calls and is free; the renderer holds the long connection. Under a load a Laravel MPA feels first in its PHP workers, a BAP feels in the renderer, which is the cheaper process to scale.
- Typed end to end. Routes, search params, actions and their inputs are
typed by the build; a link to a page that does not exist fails
tsc, not the visitor. Backend function names and arguments are next. - Any backend. Laravel and Go today; the contract is one endpoint, so a Rails or .NET app is a small package, not a port.
And what it costs, said plainly:
- Two processes. A JavaScript runtime beside the backend, in
development and in production.
rsc:installand the docs make it one command; it is still two things to run. - A hop per read. Loopback, batched, deduped by
cache(), and gone for frozen pages — but a page that reads per visitor asks the backend, and under PHP-FPM that is a framework boot per request. Octane makes it a millisecond. - A new shape to learn. The pages are React on the server — server components, actions, streaming — which is not the mental model of either a Blade view or a React SPA, and the backend’s job changes from serving pages to answering them.
How it works
browser → renderer ──render──▶ React
│
├─ before rendering: POST /__rsc/host-call { "function": "__rsc.middleware", "args": [["auth"]] }
└─ during rendering: POST /__rsc/host-call { "calls": [{ "function": "Orders.recent", "args": [5] }, …] }
│
▼
your backend- The renderer is the front door. It is what
vite devserves and what Nitro builds into.output/server: it routes, renders, serves the pages it froze at build time and the assets, and streams the rest. - One endpoint, one direction. The renderer calls the backend, never the reverse, with a shared secret the backend checks on every call. The visitor’s cookie travels with the call, so the backend’s session and auth are the visitor’s. The backend needs no credential to reach the renderer; the renderer’s pages are the public site.
- Everything else stays the backend’s. Any url the React tree does not
own —
/login, a webhook, an OAuth callback, a file under/storage, an admin panel — is forwarded to the backend as it is. Per url the rule is: if the React tree has it, React renders it; otherwise the backend does. Nothing is half-and-half on one url. - Calls are batched, and answered as they finish. Sibling components
each awaiting
rpc()are one request to the backend, which answers one line per call the moment that call is done — so a fast read paints while a slow one is still running, and batching costs the page none of its streaming. A guarded page is typically two backend requests — the guard, then the batch of its reads.
What the backend is, then
Neither an API nor an MPA. It is the backend in the literal sense — the part of the application that is not a page: models and the database, sessions and auth, policies, validation, queues, mail, events. It does not build pages, so it is not an MPA; it does not expose a JSON API to the browser, so it is not an API server. It answers one private endpoint that only the renderer can call, with functions you write as ordinary classes.
For a Laravel app that means: Laravel stops being the app that serves pages and becomes the app that answers them. Eloquent, policies, form requests, queues, notifications — all of it stays, and none of it is behind a controller any more.
Building for it
Pages read by name. A server component calls the backend the way it would call a function, because from its side it is one:
export default async function Orders() {
const orders = await rpc<Order[]>('Orders.recent', 5)
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
}No API to design, no routes to declare, no JSON layer between a component and
a method. rpc() exists in server components during a render and nowhere
else — the browser never calls the backend directly.
What rpc() is
A global the renderer installs in its own process, not something you import:
rpc<T>(name, ...args) is one POST to the backend’s host-call endpoint
carrying { "function": "Orders.recent", "args": [5] }, the shared secret,
and the visitor’s cookie — so the backend’s session and auth are the
visitor’s — answered with the function’s return value as JSON, typed by the
T you give it. The build declares it in .rsc-kit/rsc-env.d.ts, so there is
nothing to import and no declare to write. A refusal
comes back as itself — a validation error, unauthenticated, unauthorized, a
redirect — and reaches the page as that kind, never as a 500. Calls made by
sibling components in the same tick travel as one batch and are answered in
order; cache() dedupes a repeated call within the request.
It costs the browser nothing. rpc() is server-side only: the browser bundle
carries no client for it, no endpoint url, no secret — a "use client" file
cannot call it, and the host-call endpoint is reachable from the renderer
alone. The same is true of the whole backend half: a BAP’s server bundle has
no database driver, no ORM, no auth library in it, because the backend owns
those, which is why the built server for a Laravel or Go app is a fraction of
the size of the same pages with their data layer in JavaScript.
What the backend tells the build
The backend writes rsc-host.json at the project root, beside
vite.config.ts:
{
"actions": { "ordersCreate": "Orders.create" },
"functions": ["Orders.create", "Orders.recent"]
}actions are its server actions, by the name the app imports them under; the
build writes a "use server" stub for each in src/server-actions.generated.ts.
functions are the names rpc() may be called with, and become the type of
its first argument, so rpc('Orders.recnet') fails the typecheck.
Have Vite write it, so it cannot go stale. hostManifest runs the backend’s
command as vite and vite build start:
rscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend' } })
rscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } })A build fails if the command does. Under dev it is reported, and the file already there is used. A dev server restarts when the file changes.
watch names the backend’s source, relative to the project root. The dev
server runs the command again when a file there changes, so a function added
in Go is typed a moment after it is saved:
hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend', watch: ['backend'] }If you commit rsc-host.json or the generated types, fail CI when they are
stale. rsc-kit-typegen --check regenerates them as a build would, compares
the committed ones, puts them back, and exits non-zero naming each that
changed. A file you do not commit is written by every build and cannot be
stale.
Testing it
createTestApp answers the backend in the test, through the same client the
app uses against a real one, so nothing has to be running:
import { createTestApp, hostReply, HOST_MIDDLEWARE } from '@rsc-kit/core/testing'
const app = await createTestApp({
host: {
'Orders.recent': ({ args }) => [{ id: 1, total: Number(args[0]) * 100 }],
'Orders.create': ({ args }) => (args[0] ? { created: args[0] } : hostReply.invalid({ name: ['Required.'] })),
// The guards middleware.ts names: true lets the page render.
[HOST_MIDDLEWARE]: ({ headers }) =>
headers.get('cookie')?.includes('session=') ? true : hostReply.unauthenticated(),
},
})
expect((await app.fetch('/admin')).status).toBe(401)Each handler gets the call’s args and the visitor headers the renderer
forwarded.
The handlers are typed from rsc-host.json. A handler for a function the
backend does not have is a type error, its args are the function’s
parameters, and it returns the function’s result type or a hostReply
answer, never another shape. So a fake cannot drift from the backend in a way
the typecheck misses: removing a field in Go fails the test that still sends
it. hostReply itself passes the same
conformance suite
the Laravel and Go adapters do, so each answer is the one they send. Return a value for the result, or one of the protocol’s answers:
hostReply.unauthenticated(), unauthorized(), redirect(to),
refuse(status, message), invalid(errors), fail(message) for an
unexpected error, or revalidating(result, ...regions). A name with no handler fails the call, naming it.
A url the app does not own, such as a Go route or /login, is forwarded to the
backend, and in a test that is a 502. backend answers those instead, handed
the request as the backend would receive it:
const app = await createTestApp({
host: { /* … */ },
backend: (request) => new Response('the login page', { headers: { 'content-type': 'text/html' } }),
})Guards are the backend’s, named in middleware.ts. The backend decides
whether a route may render, in its own vocabulary, and the renderer asks
before anything at or below that directory renders — including a page frozen
at build time:
export const middleware = ['auth', 'can:manage-orders']A refusal is its own kind on the wire — unauthenticated, unauthorized, a redirect, a throttle’s 429 — and reaches the page as that, never as a 500.
Mutations are server actions the backend implements. A function the
backend registers as an action gets a "use server" stub written by the
build, and a client component imports and calls it. The action says what it
made stale (Rsc::revalidate('orders'), rsckit.Revalidate(ctx, "orders"))
and the answer carries the re-rendered region back.
Browser-driven reads are queries that call rpc(). A query() is a
JavaScript server function, so its body can be one call into the backend, and
the browser reads it over GET with fetchQuery, usePolling, TanStack or
SWR on top:
export const recentOrders = client.query(async () => rpc<Order[]>('Orders.recent', 5))Put things where they belong. Data, identity, rules and jobs are the backend’s; layout, interaction and everything a visitor sees is React’s. A page that needs nothing from the backend is frozen at build time and never touches it; one that reads per visitor is a shell with holes the backend fills. The build’s table says which is which.
Running it
Two processes, and the decision that matters is which faces the internet.
The renderer in front is the arrangement to reach for: it serves assets and frozen pages straight off disk, renders the rest, forwards what it does not own, and holds a backend worker for the length of a host call, never a render. Restrict the host-call endpoint at the web server so only the renderer reaches it.
The backend in front proxies pages to the renderer — the dev-mode shape,
and what a Laravel app does with RSC_RENDERER_URL. It holds a worker for the
whole render while the render calls back for data, so it needs several
workers and caps concurrency at one fewer than it has.
What a call costs is the backend handling a request — under PHP-FPM a framework boot, under Octane or a Go process about a millisecond — times the number of sequential rounds a page needs, which batching keeps to one or two.
When to choose it
A BAP earns its second process when the pages want what a server-rendered React app gives — streaming, server components with no client bundle, static and partially-prerendered pages, typed routes and actions — and the data, identity and rules already live in a backend you are keeping. An app whose backend is only a database is better as a plain rsc-kit app, where the “backend” is an import. An app whose pages are simple forms over a Laravel model may be happier as an MPA. Everything in between is what this is for.
Next: Laravel · Go · Your own backend