Full-stack TodoMVC with a Rust API and a React frontend, connected by generated TypeScript types.
Demonstrates the pattern: define data types once in Rust, derive TypeScript interfaces with ts-rs, and use them in the frontend — no manual type synchronization.
| Layer | Tech |
|---|---|
| API | Axum + SQLx (direct queries, no ORM) |
| Database | PostgreSQL |
| Type bridge | ts-rs — Rust structs → TypeScript interfaces |
| Frontend | React 19, Vite, TanStack Query |
| Migrations | SQLx built-in (sqlx::migrate!) |
src/
main.rs # Axum server setup
lib.rs # Shared library root
models.rs # TodoRow (FromRow) + Todo/Request/Response (Serialize + TS)
handlers.rs # CRUD handlers
queries.rs # Direct sqlx queries (cfg-gated postgres/sqlite variants)
error.rs # AppError → IntoResponse
bin/migrate.rs # SQLx migration runner (postgres)
migrations/
0001_todos.sql
web/
src/
api.ts # Fetch client using generated types
components/ # TodoApp, TodoItem, TodoFooter
types/generated # ts-rs output (re-exported via index.ts)
scripts/
setup-db.sh # Create database + run migrations
generate-types.sh # Generate TypeScript from Rust structs
- Rust (stable)
- Node.js 18+
- PostgreSQL running locally
cp .env.example .env
# Edit .env if your Postgres connection differs from the default
bash scripts/setup-db.shbash scripts/generate-types.shThis runs cargo test with TS_RS_EXPORT_DIR set, which writes .ts files to web/src/types/generated/.
source .env
cargo run --bin todo-api
# Listening on http://0.0.0.0:3001cd web
npm install
npm run dev
# http://localhost:5173| Method | Path | Description |
|---|---|---|
GET |
/api/todos?filter=all|active|completed |
List todos |
POST |
/api/todos |
Create a todo |
PATCH |
/api/todos/{id} |
Update title and/or completed |
DELETE |
/api/todos/{id} |
Delete a todo |
POST |
/api/todos/toggle-all |
Toggle all todos |
DELETE |
/api/todos/completed |
Clear completed todos |
DELETE |
/api/test/cleanup |
Delete all todos (test-helpers feature only) |
Integration tests live in web/tests/ and hit the API over HTTP using the same generated types the frontend uses. They run with Vitest.
# Start the API against the test database with test-helpers enabled (terminal 1)
DATABASE_URL=postgres://localhost/todo_app_test cargo run --features test-helpers --bin todo-api
# Run tests (terminal 2)
cd web && npm testThe test-helpers Cargo feature flag enables DELETE /api/test/cleanup, which tests use to clear data before each test case. This endpoint is not compiled into the binary without the feature flag.
Separate DB and API types. TodoRow uses sqlx::FromRow and maps 1:1 to the database schema (with native Uuid and DateTime<Utc>). Todo uses Serialize + TS and represents the JSON shape sent to the client (with String IDs and RFC 3339 timestamps). A From<TodoRow> impl bridges them. This separation means the database schema and API contract can evolve independently — adding a DB column doesn't force a frontend change until you're ready.
Direct SQL, no ORM. All SQL lives in src/queries.rs as compile-time verified sqlx::query! calls, with cfg-gated postgres and sqlite variants. Handlers in src/handlers.rs are cfg-free and only deal with HTTP/validation logic. The postgres and sqlite query functions are intentionally duplicated rather than abstracted with a macro — see docs/query-layer-duplication-rationale.md for the analysis and tradeoffs.
Generated types as the contract. The TypeScript types come from the Rust structs, not the other way around. If a Rust struct field changes, the frontend won't compile until types are regenerated. This catches integration mismatches at build time rather than runtime.
TanStack Query, no client state library. All todo data lives on the server. The frontend uses useQuery to fetch and useMutation + invalidateQueries to write. There's no reducer, no store, no synchronization logic — the server is the source of truth.
Rust structs annotated with #[derive(TS)] and #[ts(export)] generate TypeScript interfaces when tests run:
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Todo {
pub id: String,
pub title: String,
pub completed: bool,
pub created_at: String,
pub updated_at: String,
}Produces:
export type Todo = {
id: string;
title: string;
completed: boolean;
createdAt: string;
updatedAt: string;
};The frontend imports these types and uses them in the fetch client — if the Rust API shape changes, the TypeScript won't compile until the types are regenerated.
MIT