Skip to content
★ GitHubGet started

Build a typed React SPA

Goal: build a single-page app against your Loco backend where the TypeScript types come from your Rust types — not from a hand-maintained copy that silently drifts.

This is what loco new produces when you pick clientside assets. If you already have an app, the layout below is what you need to add.

A clientside app is one Cargo project with a frontend/ directory inside it:

myapp/
├── src/
│ ├── controllers/ # your JSON API
│ └── dtos/ # the wire types — the source of truth
├── frontend/
│ ├── package.json # react 19, react-router, @tanstack/react-query
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx # QueryClientProvider + RouterProvider
│ ├── routes.tsx # the route table
│ ├── api/client.ts # fetch wrapper: bearer token, error mapping
│ ├── bindings/ # GENERATED TypeScript — never edit by hand
│ ├── auth/ # token storage, Login, RequireAuth
│ └── pages/
└── config/

Two directories carry the whole idea: src/dtos/ holds Rust types, and frontend/src/bindings/ holds their TypeScript equivalents, generated.

A DTO is a plain Rust struct that derives ts_rs::TS:

use ts_rs::TS;
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct PostDto {
#[ts(type = "number")]
pub id: i64,
pub title: String,
pub status: PostStatus,
#[ts(type = "string")]
pub price: Decimal,
#[ts(type = "string | null")]
pub published_at: Option<DateTimeWithTimeZone>,
}

#[ts(type = "...")] is how you pin the wire shape of a type ts-rs can’t infer on its own — i64 is a JavaScript number, and a Decimal crosses the wire as a string so it doesn’t lose precision.

Keep DTOs separate from your Sea-ORM entities and convert at the edge:

impl From<crate::models::_entities::posts::Model> for PostDto {
fn from(m: crate::models::_entities::posts::Model) -> Self {
Self { id: m.id, title: m.title, status: PostStatus::from(m.status), .. }
}
}

That From is the seam. Your database schema can change without changing your API, and when you do want the API to change, the compiler walks you through it.

#[ts(export)] generates a test. The .ts files are written when you run:

Terminal window
cargo test

That is the whole command — there is no separate export step and no build script. Bindings are refreshed as a side effect of the test suite, which means CI regenerates them on every run and a stale binding shows up as a diff.

After changing a DTO, run cargo test, then rebuild the frontend. A field you removed in Rust is now a TypeScript compile error in every page that read it.

With a frontend/ present, scaffold is adaptive — it generates the backend and the frontend:

Terminal window
cargo loco generate scaffold post title:string content:text status:enum:draft,published

You get the usual model, migration, and controller, plus:

File What it is
src/dtos/posts.rs PostDto, CreatePost, UpdatePost, enums — all #[ts(export)]
frontend/src/api/posts.ts typed TanStack Query hooks: useListPosts, usePost, useCreatePost, useUpdatePost, useRemovePost
frontend/src/pages/posts/ List, Show, New, Edit
frontend/src/routes.tsx imports and routes, injected at the // scaffold:imports and // scaffold:routes anchors

Those two anchor comments must stay in routes.tsx. They are how the generator finds its place; if you delete them, the next scaffold fails with an explicit error rather than silently generating pages nothing routes to.

The generated hooks own their cache invalidation, so a create or delete refreshes the list without any wiring on your part:

export function List() {
const { data, isLoading, isError, error } = useListPosts();
const removePost = useRemovePost();
// ...
}

Run the two servers side by side:

Terminal window
cargo loco start # :5150 — the API
cd frontend && pnpm install && pnpm dev # :5173 — Vite, with HMR

Develop against http://localhost:5173. Vite proxies /api to the backend, so the browser sees one origin and there is no CORS to configure:

frontend/vite.config.ts
server: { port: 5173, proxy: { '/api': 'http://localhost:5150' } }

Build the frontend, then start the app:

Terminal window
cd frontend && pnpm build # writes frontend/dist/
cargo loco start

Loco serves the bundle through the static middleware, already configured for you:

server:
middlewares:
fallback:
enable: false
static:
enable: true
must_exist: true
folder:
uri: "/"
path: "frontend/dist"
fallback: "frontend/dist/index.html"

The fallback key inside static is what makes client-side routing survive a hard refresh: a request for /posts/42 matches no file, so index.html is served and React Router takes over.

To ship a single self-contained binary with the bundle compiled in, see embedded_assets.

frontend/src/api/client.ts attaches the JWT to every request and handles expiry centrally:

const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// ...
if (res.status === 401) {
clearToken();
window.location.href = "/login";
}

Route protection is one component — RequireAuth renders an <Outlet /> when a token is present and redirects otherwise:

export function RequireAuth() {
if (getToken() === null) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

Scaffolded routes are injected inside the RequireAuth branch of the route table, matching the backend: generated controllers require a JWT. See JWT authentication for the server side.

The generated token store uses localStorage. That is the simplest thing that works for a getting-started app; if XSS-resistant storage matters for your threat model, move the token to an httpOnly cookie and switch the server to the cookie JWT location — see JWT locations.

examples/reference_spa in the Loco repository is a full working app built exactly this way — DTOs with enums and decimals, generated bindings, typed hooks, and the four scaffolded pages. It is the app loco new + generate scaffold reproduces, and it is exercised by the test suite, so it stays honest.