---
title: "Backend-Answered Pages"
description: "BAP — the model for an rsc-kit app with a backend behind it, and how to build for it."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Backend-Answered Pages

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](/hosts/laravel) and
[Go](/hosts/go) pages are how each backend answers, and
[Your own backend](/hosts/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

```text
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:

```tsx title="src/app/orders/page.tsx"
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`:

```json title="rsc-host.json"
{
  "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:

```ts title="vite.config.ts"
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:

```ts
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:

```ts title="tests/app.test.ts"
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](/hosts/your-own-backend#before-you-ship-the-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:

```ts
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:

```ts title="src/app/admin/middleware.ts"
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](/guides/queries) on top:

```ts
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](/hosts/laravel) · [Go](/hosts/go) · [Your own backend](/hosts/your-own-backend)

Source: https://docs.rsc-kit.dev/hosts/backend-answered-pages/index.mdx
