Web apps
A Reboot web app is an ordinary React single-page app. What Reboot adds is that it talks to your backend through generated, typed hooks that are reactive — React components re-render when backend state changes, without polling — and that its users sign in through your backend's own OAuth server.
This page covers how a web frontend is put together. For the full hooks reference, see Call your API from React.
Install and generate
Add the React client to your frontend's package.json:
npm install -S @reboot-dev/reboot-react
Tell rbt generate where to emit the typed client, in your
.rbtrc:
generate --react=frontend/api
Generating into frontend/api/ — a sibling of the app itself — means
every frontend of your application (web, mobile, MCP Apps) shares one
client. Keep your bundler's root at frontend/ so that directory is
inside it; if your app root is frontend/web/ instead, Vite needs
server.fs.allow: [".."] to read a sibling.
Point the client at your backend
Wrap your app in a RebootClientProvider with the backend's URL:
// frontend/web/src/main.tsx
import { RebootClientProvider } from "@reboot-dev/reboot-react";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RebootClientProvider url={import.meta.env.VITE_REBOOT_URL}>
<Root />
</RebootClientProvider>
</StrictMode>
);
Read the URL from the environment rather than hard-coding it. When
url is undefined the client uses the page's own origin, which is
right whenever the backend serves the web app: in development with
--web-app (Envoy sends / to the Vite dev server, see
Develop locally),
or built with --web-app-dist-path. A web app served from anywhere
else — a CDN or static host in production, or Vite's own URL opened
directly — is on a different origin from the backend, so it must
set it:
# frontend/web/.env.production
VITE_REBOOT_URL="https://your-backend-host.example.com"
Signing in
Reboot's OAuth server does the work; your app needs three hooks.
import { useUser } from "@api/todos/v1/todos_rbt_react";
import { useSignIn, useSignOut } from "@reboot-dev/reboot-react";
export const Root = () => {
const { user, isLoading } = useUser();
const signIn = useSignIn();
const signOut = useSignOut();
// The provider renders immediately and resolves the session in the
// background, so `isLoading` holds until it lands.
if (isLoading) {
return <p>Checking session…</p>;
}
if (user === undefined) {
return <button onClick={() => signIn()}>Sign in</button>;
}
return <App user={user} onSignOut={() => void signOut()} />;
};
useSignIn()returns a function that starts the sign-in flow. The browser goes to your identity provider and comes back signed in, to where the user was. Pass areturnToto send them somewhere else.useSignOut()drops this device's session. The identity provider's own session is left alone.useUser()is generated from yourUsertype, so it is typed to your API. Called with no ID it resolves the signed-in user's ownUser— the instance Reboot auto-constructed at sign-in.userisundefinedwhen nobody is signed in.
The user handle exposes your User methods: reads as nested hooks,
mutations as plain calls.
const { response } = user.useListTodoLists();
await user.createTodoList({ title: "Groceries" });
isLoading firstWhile isLoading is true, user is still undefined. Reaching the
undefined check after loading has finished is what genuinely means
"nobody is signed in".
Other state types
Generated hooks exist for every state type, not just User:
const todoList = useTodoList({ id: todoListId });
const { response } = todoList.useTodos();
await todoList.add({ todo: "Milk" });
Where does todoListId come from? From the person's User, the one
instance you can reach without an ID: it records what they own, and a
component reads those IDs off it and passes each one down.
const TodoLists = ({ user }: { user: UseUserApi }) => {
const { response } = user.useListTodoLists();
return (response?.todoLists ?? []).map((todoList) => (
<TodoListView key={todoList.todoListId} id={todoList.todoListId} />
));
};
const TodoListView = ({ id }: { id: string }) => {
const todoList = useTodoList({ id });
const { response } = todoList.useTodos();
return (
<ul>
{(response?.todos ?? []).map((todo) => (
<li key={todo}>{todo}</li>
))}
</ul>
);
};
Backend configuration
Two things on the backend matter to a web frontend.
1. allowed_origins. Your SPA is on a different origin than the
backend, so it must be allow-listed for credentialed requests:
oauth=OAuth(
provider=...,
allowed_origins=["https://app.example.com"],
)
In development, http://localhost and http://127.0.0.1 on any port
are allowed automatically. In production, omitting allowed_origins
is a startup error. See Configure OAuth.
2. The callback URL. Register
<your-backend-url>/__/oauth/callback with your identity provider —
the backend's URL, not the SPA's.
Local development
Run two processes: the backend, and the Vite dev server.
rbt dev run
cd frontend && npm run dev
rbt dev run generates the React client before it starts, so the dev
server always has an up-to-date frontend/api/. The frontend's
package.json mirrors the .rbtrc configs below: npm run dev is
the default, dev:portless (portless frontend-<app> vite, with portless as
a dev dependency), and dev:ports is plain vite; see
Named local URLs.
If you want Reboot to serve the frontend too — so both are on one
origin, and so UI methods work — tell it where the frontend is in
.rbtrc, and what to serve from it:
# Named URLs via portless: Vite, run as the frontend's `npm run dev:portless`
# (`portless frontend-<app> vite`), is at https://frontend-<app>.localhost; Envoy
# proxies `/__/frontend/**` there, and `/` too, where it serves the web app.
dev run --default-config=portless
dev run:portless --portless
dev run:portless --frontend-url=https://frontend-<app>.localhost
dev run:portless --mcp-app
dev run:portless --web-app
# Plain ports: `rbt dev run --config=ports`, Vite from `npm run dev:ports`.
dev run:ports --frontend-url=http://localhost:5173
dev run:ports --mcp-app
dev run:ports --web-app
# Dist: serve the built MCP Apps and web app instead. `rbt dev run --config=dist`.
dev run:dist --mcp-app-dist-path=frontend/dist/mcp
dev run:dist --web-app-dist-path=frontend/dist/web
# The developer dashboard too: https://dashboard-<app>.localhost.
dashboard --default-config=portless
dashboard:portless --portless
dashboard:ports --no-portless
See Develop locally for what each flag does.
Going to production
The built SPA can be served by the backend itself, at /, or by a
static host on its own origin.
Served by the backend. Build the frontend (npm run build) and
tell rbt serve where the builds landed, the MCP Apps and the SPA;
the backend serves the SPA at /, with its index.html answering
any path the app has no route or file for, so the SPA's own routing
works on reload:
serve run --mcp-app-dist-path=frontend/dist/mcp
serve run --web-app-dist-path=frontend/dist/web
The SPA is same-origin with the backend, so it needs no
VITE_REBOOT_URL and no allowed_origins entry. To try the same
thing locally, rbt dev run --config=dist with the same flags.
Served by a static host.
- Build the SPA (
npm run build) and publish it to any static host. - Set
VITE_REBOOT_URLto the backend's public origin at build time. - Deploy the backend — Reboot Cloud or your own infrastructure.
- Add the SPA's origin to
allowed_origins, and the backend's public URL plus/__/oauth/callbackto your identity provider.
Browsers only use HTTP/2 over TLS. Over plain HTTP, every reactive
reader holds its own WebSocket, and browsers allow only around 200
of those per page (255 in Chrome), so an app with many concurrent
readers needs HTTPS.
See the rbt CLI
for local certificates.
Next
- Call your API from React — reactive reads, optimistic updates, offline caching, Next.js server components.
- Users and sign-in — what
useUser()is resolving. - React Native apps — the same code on a phone (alpha).