React + Axum: Building a Type-Safe Full-Stack Application
What if your frontend and backend could share the same type definitions? No more mismatched API responses. No more runtime surprises. In this post, we’ll build a full-stack application with React and Axum that achieves exactly that.
The Architecture
Our stack:
- Backend: Rust with Axum
- Frontend: React with TypeScript
- Type Bridge: JSON Schema or manual type definitions
The key insight is that both TypeScript and Rust have strong type systems. By defining our API contract carefully, we can ensure type safety across the entire stack.
Project Structure
fullstack-app/
├── backend/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
├── frontend/
│ ├── package.json
│ ├── src/
│ │ ├── App.tsx
│ │ ├── api/
│ │ │ └── client.ts
│ │ └── types/
│ │ └── api.ts
└── shared/
└── types.ts # Reference for both sides
Step 1: Define the API Types
First, let’s define our shared types. We’ll write them in both languages to match:
TypeScript Types (frontend/src/types/api.ts)
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
export interface CreateTodoRequest {
title: string;
}
export interface UpdateTodoRequest {
title?: string;
completed?: boolean;
}
export interface ApiError {
error: string;
code: string;
}
Rust Types (backend/src/main.rs)
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodoRequest {
pub title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTodoRequest {
pub title: Option<String>,
pub completed: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ApiError {
pub error: String,
pub code: String,
}
Notice #[serde(rename_all = "camelCase")] - this ensures Rust’s snake_case fields become JavaScript’s camelCase in JSON.
Step 2: Build the Axum Backend
Cargo.toml
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6", features = ["cors"] }
Full Backend Code
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::cors::{Any, CorsLayer};
use uuid::Uuid;
// Types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodoRequest {
pub title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTodoRequest {
pub title: Option<String>,
pub completed: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ApiError {
pub error: String,
pub code: String,
}
// State
type TodoStore = Arc<Mutex<HashMap<Uuid, Todo>>>;
// Error handling
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"BAD_REQUEST" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(self)).into_response()
}
}
// Handlers
async fn list_todos(State(store): State<TodoStore>) -> Json<Vec<Todo>> {
let todos = store.lock().await;
let mut list: Vec<Todo> = todos.values().cloned().collect();
list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Json(list)
}
async fn create_todo(
State(store): State<TodoStore>,
Json(input): Json<CreateTodoRequest>,
) -> Result<(StatusCode, Json<Todo>), ApiError> {
if input.title.trim().is_empty() {
return Err(ApiError {
error: "Title cannot be empty".into(),
code: "BAD_REQUEST".into(),
});
}
let todo = Todo {
id: Uuid::new_v4(),
title: input.title,
completed: false,
created_at: Utc::now().to_rfc3339(),
};
store.lock().await.insert(todo.id, todo.clone());
Ok((StatusCode::CREATED, Json(todo)))
}
async fn get_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, ApiError> {
let todos = store.lock().await;
todos.get(&id).cloned().map(Json).ok_or(ApiError {
error: "Todo not found".into(),
code: "NOT_FOUND".into(),
})
}
async fn update_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodoRequest>,
) -> Result<Json<Todo>, ApiError> {
let mut todos = store.lock().await;
let todo = todos.get_mut(&id).ok_or(ApiError {
error: "Todo not found".into(),
code: "NOT_FOUND".into(),
})?;
if let Some(title) = input.title {
todo.title = title;
}
if let Some(completed) = input.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let mut todos = store.lock().await;
todos.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError {
error: "Todo not found".into(),
code: "NOT_FOUND".into(),
})
}
#[tokio::main]
async fn main() {
let store: TodoStore = Arc::new(Mutex::new(HashMap::new()));
// CORS configuration for development
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/api/todos", get(list_todos).post(create_todo))
.route("/api/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.layer(cors)
.with_state(store);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
println!("Backend running on http://localhost:3001");
axum::serve(listener, app).await.unwrap();
}
Step 3: Build the React Frontend
API Client (frontend/src/api/client.ts)
import type { Todo, CreateTodoRequest, UpdateTodoRequest, ApiError } from '../types/api';
const API_BASE = 'http://localhost:3001/api';
class ApiClient {
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.error);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
async listTodos(): Promise<Todo[]> {
return this.request<Todo[]>('/todos');
}
async createTodo(data: CreateTodoRequest): Promise<Todo> {
return this.request<Todo>('/todos', {
method: 'POST',
body: JSON.stringify(data),
});
}
async getTodo(id: string): Promise<Todo> {
return this.request<Todo>(`/todos/${id}`);
}
async updateTodo(id: string, data: UpdateTodoRequest): Promise<Todo> {
return this.request<Todo>(`/todos/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteTodo(id: string): Promise<void> {
return this.request<void>(`/todos/${id}`, {
method: 'DELETE',
});
}
}
export const api = new ApiClient();
React App (frontend/src/App.tsx)
import { useState, useEffect } from 'react';
import { api } from './api/client';
import type { Todo } from './types/api';
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTitle, setNewTitle] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
setLoading(true);
const data = await api.listTodos();
setTodos(data);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load todos');
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
try {
const todo = await api.createTodo({ title: newTitle });
setTodos([todo, ...todos]);
setNewTitle('');
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create todo');
}
};
const handleToggle = async (todo: Todo) => {
try {
const updated = await api.updateTodo(todo.id, {
completed: !todo.completed,
});
setTodos(todos.map(t => (t.id === todo.id ? updated : t)));
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update todo');
}
};
const handleDelete = async (id: string) => {
try {
await api.deleteTodo(id);
setTodos(todos.filter(t => t.id !== id));
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete todo');
}
};
if (loading) {
return <div className="loading">Loading...</div>;
}
return (
<div className="app">
<h1>Todo App</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={handleCreate}>
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
/>
<span>{todo.title}</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default App;
Step 4: Run Both Services
In one terminal:
cd backend
cargo run
In another terminal:
cd frontend
npm run dev
Your frontend at http://localhost:5173 will communicate with the backend at http://localhost:3001.
Type Safety Benefits
With this setup, you get:
- Compile-time API validation: TypeScript catches if you use the wrong field names
- Autocomplete: Your IDE knows exactly what fields
Todohas - Refactoring confidence: Rename a field in the type, and TypeScript shows every usage
- Documentation: Types serve as living documentation of your API
Going Further: Automated Type Generation
For larger projects, consider generating TypeScript types from Rust:
- ts-rs: Generates TypeScript definitions from Rust structs
- OpenAPI: Generate a spec from Axum, then generate TS types from that
Example with ts-rs:
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
}
Run cargo test and it generates bindings/Todo.ts automatically.
Production Considerations
Before deploying:
- Environment variables: Use proper config for API URLs
- Error boundaries: Add React error boundaries for graceful failures
- Authentication: Add JWT or session-based auth
- Validation: Validate inputs on both client and server
- CORS: Configure properly for production domains
Final Thoughts
This stack gives you the best of both worlds: React’s excellent developer experience for UI, and Rust’s performance and safety for the backend. The shared type definitions mean you catch integration bugs at compile time, not in production.
The initial setup takes more work than a JavaScript-only stack, but the long-term benefits are significant. As your application grows, the type safety pays dividends in maintainability and confidence when making changes.
Start small, get the types right, and build from there.