.offrails/guides/frontend-react.md
Frontend: spa mode with React (SPA + JSON API)
TL;DR
spamode with React: a Vite SPA inweb/(React Router 8 + TanStack Query) talking to the Rust API under/api. Choose it only for a public API or mobile clients; otherwise useinertia.- Types come from ts-rs;
web/src/api/client.tsis the onlyfetchcaller; per-resource hooks wrap it.- Relative
/api/...paths only; the SPA fallback must not swallow/api404s.- Type-check with
tsc -b; installreact-router(notreact-router-dom).Read the rest when building SPA routes, the API client, or SPA tests.
This file was react mode before 0.3.0; the mode is now spa, with UI_LIBRARY: react. For UI_LIBRARY: svelte (SvelteKit in SPA mode), read frontend-svelte, which keeps this file's /api contract.
A Vite-built React SPA in web/, talking to a Rust JSON API under /api. Choose this mode only when the interview found a reason: a public API, or a mobile/third-party client sharing it. Otherwise React apps use inertia.
Also read: controllers.md#json-api for the server side.
Shape
browser ──► Rust binary (:3000)
├── /api/* JSON handlers (controllers/api/)
├── /up health check
├── /assets/* web/dist/assets (hashed, immutable)
└── GET * SPA fallback → web/dist/index.html- Production:
routes.rsservesweb/distwithServeDir, plus a fallback that returnsweb/dist/index.htmlfor anyGETnot under/api,/assetsor/upthat acceptstext/html./api/*misses still 404 as JSON. - Development: Vite on
:5173withserver.proxyforwarding/api,/up,/streams(SSE),/blobsand/devtohttp://localhost:3000(bin/devruns the Rust server without TLS inspamode). Browse:5173. Same origin from the browser's view, so cookies just work. Vite proxies over HTTP/1.1, so check many live tabs against the built app (realtime).
Setup (verified with Vite 8, React 19, React Router 8, TanStack Query 5)
Scaffold and convert exactly as in the "Vite wiring" section of frontend-inertia.md (cleanup, Biome, tsconfig, scripts, tsc -b), with these differences:
- Keep
index.html(the SPA shell):<div id="root"></div><script type="module" src="/src/main.tsx"></script>. - Packages:
pnpm add react-router @tanstack/react-queryinstead of the Inertia packages. Onlyreact-router.react-router-domis a v7-era compatibility package; don't install it. - No
build.rolldownOptions.input(the entry isindex.html).build.manifestisn't needed. vite.config.tsserver.proxy:{ "/api": "http://localhost:3000", "/up": "http://localhost:3000", "/streams": "http://localhost:3000", "/blobs": "http://localhost:3000", "/dev": "http://localhost:3000" }.
Types: Rust is the source of truth
- Every request/response type the SPA sees derives
ts_rs::TSwith#[ts(export)]:<Model>Params(in models),<Model>Json,<Plural>Page,Errors(in views). .cargo/config.toml:
[env]
TS_RS_EXPORT_DIR = { value = "web/src/types", relative = true }
# i64 ids arrive as JSON numbers; ts-rs 12 defaults i64 to `bigint`, which type-checks but is wrong at runtime.
TS_RS_LARGE_INT = "number"ts-rsexports duringcargo test, via the tests it generates. So the Definition of Done runscargo nextest runbeforepnpm --dir web exec tsc -b. A renamed Rust field then breaks the TS build. That's the point.web/src/types/is generated: commit it, never edit it. CI checks it's up to date (git diff --exit-code web/src/types).- Check the resolved
ts-rsversion's docs for attribute names and export behavior. They have changed between versions.
web/src/api/client.ts: the only fetch
import type { Errors } from "@/types/Errors";
export class ApiError extends Error {
constructor(public status: number, public errors: Errors | null) { super(`API ${status}`); }
}
export async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, {
method,
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return undefined as T;
if (!res.ok) throw new ApiError(res.status, res.status === 422 ? (await res.json()).errors : null);
return res.json() as Promise<T>;
}A 401 anywhere → redirect to /login (handled once in the QueryClient default onError).
Per-resource hooks: web/src/api/<resource>.ts
export const postKeys = { all: ["posts"] as const, one: (id: number) => ["posts", id] as const };
export const usePosts = (page = 1) =>
useQuery({ queryKey: [...postKeys.all, page], queryFn: () => api<PostsPage>("GET", `/posts?page=${page}`) });
export const useCreatePost = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (p: PostParams) => api<PostJson>("POST", "/posts", p),
onSuccess: () => qc.invalidateQueries({ queryKey: postKeys.all }),
});
};Pages never call api() directly. They use hooks.
Routing & pages
web/src/main.tsx: data/SPA mode, never framework mode (no@react-router/dev, noreact-router.config.ts):
import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import { routes } from "@/routes";
const queryClient = new QueryClient();
const router = createBrowserRouter(routes);
const root = document.getElementById("root");
if (!root) throw new Error("#root missing");
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);web/src/routes/index.tsxexportsroutes: RouteObject[](importRouteObjectfromreact-router), so tests can mount the same tree withcreateMemoryRouter(routes, { initialEntries: ["/posts"] }).- Pages:
web/src/routes/<plural_kebab>/{Index,Show,New,Edit,Form}.tsx. Path constants inweb/src/paths.ts, mirroringroutes::paths. - Forms: controlled inputs over a
PostParamsstate object. OnApiErrorwitherrors, rendererrors[field]under each input using Rails wording ("Title can't be blank"). - Flash: client-side. After a successful mutation, navigate and pass a message via router state, which
components/Layout.tsxrenders.
Auth
Same cookie session as every other mode (auth.md), via JSON endpoints: POST /api/session (login), DELETE /api/session (logout), GET /api/session (current user or 401). No JWTs, no localStorage tokens.
Realtime
new EventSource("/streams/posts") → on each signal, queryClient.invalidateQueries({ queryKey: postKeys.all }). Signals, not data (realtime.md).
Testing
- Vitest + Testing Library for components and hooks (mock
api(), neverfetchitself). Wrap renders in a freshQueryClient(retry: false) and acreateMemoryRouter. - Server behavior is tested in Rust (
testing.md). Playwright E2E is optional, for critical flows only.
Gotchas agents get wrong
- Running
tscbeforecargo test, then "fixing" TS errors by editing generated types. - Type-checking with
tsc --noEmit, which checks nothing under the template's project references. Usetsc -b. - Installing
react-router-dom, or importingRouterProviderfromreact-routerinstead ofreact-router/domin the browser entry. - Duplicating a Rust type by hand in TS "just for now".
- Using
localhost:3000URLs in the SPA. Always use relative/api/...paths. - SPA fallback swallowing
/api404s and returningindex.htmlwith 200. - Using React Router framework mode or loaders that assume SSR.
- Forgetting
credentials: "same-origin"in tests' fetch mocks, or adding CORS. Same origin needs no CORS.