Skip to content

Backend-Answered Pages

BAP — the model for an rsc-kit app with a backend behind it, and how to build for it.

Updated View as Markdown

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:install and 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 dev serves 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:

src/app/orders/page.tsxtsx
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:

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

vite.config.tsts
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:

tests/app.test.tsts
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:

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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close