If you're learning Rust and want to build APIs, Axum is one of the best frameworks to start with.
In this tutorial, we'll build a simple CRUD API (Create, Read, Update, Delete) using:
- ✅ Axum
- ✅ In-memory storage (no database)
- ✅ Beginner-friendly concepts
What We Will Build
A simple User API with these endpoints:
| Method | Route | Description |
|---|---|---|
| POST | /users |
Create a user |
| GET | /users |
Get all users |
| GET | /user/:id |
Get a single user |
| PUT | /user/:id |
Update a user |
| DELETE | /user/:id |
Delete a user |
Step 1: Setup Dependencies
Add this to your Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Step 2: Define Our Data
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
-
User→ the stored data structure -
CreateUser→ the incoming request body
Step 3: App State (In-Memory Database)
type AppState = Arc<Mutex<Vec<User>>>;
We use:
-
Vec<User>→ to store users -
Mutex→ for safe concurrent access -
Arc→ to share state across threads
Step 4: Create User (POST)
async fn create_user(
State(state): State<AppState>,
Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
let mut users = state.lock().unwrap();
let new_id = users.last().map_or(1, |u| u.id + 1);
let new_user = User {
id: new_id,
name: body.name,
email: body.email,
};
users.push(new_user.clone());
(StatusCode::CREATED, Json(new_user))
}
Step 5: Get All Users (GET)
async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.lock().unwrap();
Json(users.clone())
}
Step 6: Get Single User
async fn get_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
let users = state.lock().unwrap();
if let Some(user) = users.iter().find(|u| u.id == id) {
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
Step 7: Update User
async fn update_user(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
let mut users = state.lock().unwrap();
if let Some(user) = users.iter_mut().find(|u| u.id == id) {
user.name = body.name;
user.email = body.email;
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
Step 8: Delete User
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut users = state.lock().unwrap();
if let Some(pos) = users.iter().position(|u| u.id == id) {
users.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
Step 9: Setup Router & Main
#[tokio::main]
async fn main() {
let state: AppState = Arc::new(Mutex::new(vec![]));
let app = Router::new()
.route("/users", get(user_list).post(create_user))
.route(
"/user/:id",
get(get_user)
.put(update_user)
.delete(delete_user),
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Full Code (Mutex Version)
use axum::{
extract::{State, Path},
http::StatusCode,
routing::{get, post, put, delete},
Json,
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
type AppState = Arc<Mutex<Vec<User>>>;
async fn create_user(
State(state): State<AppState>,
Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
let mut users = state.lock().unwrap();
let new_user = User {
id: users.len() as u32 + 1,
name: body.name,
email: body.email,
};
users.push(new_user.clone());
(StatusCode::CREATED, Json(new_user))
}
async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.lock().unwrap();
Json(users.clone())
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
let users = state.lock().unwrap();
if let Some(user) = users.iter().find(|u| u.id == id) {
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn update_user(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
let mut users = state.lock().unwrap();
if let Some(user) = users.iter_mut().find(|u| u.id == id) {
user.name = body.name;
user.email = body.email;
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut users = state.lock().unwrap();
if let Some(pos) = users.iter().position(|u| u.id == id) {
users.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[tokio::main]
async fn main() {
let state: AppState = Arc::new(Mutex::new(vec![]));
let app = Router::new()
.route("/users", post(create_user))
.route("/users", get(user_list))
.route("/user/:id", put(update_user))
.route("/user/:id", delete(delete_user))
.route("/user/:id", get(get_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Bonus: Async RwLock Version
For better read performance (multiple concurrent reads), use tokio::sync::RwLock instead of Mutex:
use axum::{
extract::State,
http::StatusCode,
routing::{get, post, put, delete},
extract::{Path, Json},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Debug, Deserialize)]
struct CreateUser {
name: String,
email: String,
}
type AppState = Arc<RwLock<Vec<User>>>;
async fn create_user(
State(state): State<AppState>,
Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
let mut users = state.write().await;
let new_id = users.last().map_or(1, |u| u.id + 1);
let new_user = User {
id: new_id,
name: body.name,
email: body.email,
};
users.push(new_user.clone());
(StatusCode::CREATED, Json(new_user))
}
async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.read().await;
Json(users.clone())
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
let users = state.read().await;
if let Some(user) = users.iter().find(|u| u.id == id) {
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut users = state.write().await;
if let Some(pos) = users.iter().position(|u| u.id == id) {
users.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
async fn update_user(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(body): Json<User>,
) -> Result<Json<User>, StatusCode> {
let mut users = state.write().await;
if let Some(user) = users.iter_mut().find(|u| u.id == id) {
user.name = body.name;
user.email = body.email;
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
#[tokio::main]
async fn main() {
let state: AppState = Arc::new(RwLock::new(vec![]));
let app = Router::new()
.route("/users", get(user_list))
.route("/users", post(create_user))
.route("/user/{id}", get(get_user))
.route("/user/{id}", delete(delete_user))
.route("/user/{id}", put(update_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Mutex vs RwLock: Use
Mutexfor simplicity. UseRwLockwhen you have many reads and few writes — it allows multiple readers simultaneously.
How to Test
Use Postman or curl to test your endpoints.
Create a user:
POST /users
Content-Type: application/json
{
"name": "John",
"email": "john@example.com"
}
Get all users:
GET /users
Get a single user:
GET /user/1
Update a user:
PUT /user/1
Content-Type: application/json
{
"name": "John Updated",
"email": "john_updated@example.com"
}
Delete a user:
DELETE /user/1
Key Concepts You Learned
- Axum routing — how to define and chain HTTP routes
-
State management — sharing data across handlers with
Arc<Mutex<T>> - JSON handling — extracting request bodies and returning JSON responses
- Path parameters — reading dynamic values from the URL
- CRUD operations — implementing all four basic data operations
What's Next?
Now that you have the basics down, you can level up by:
- Replacing in-memory storage with PostgreSQL (using
sqlx) - Adding input validation (using
validator) - Adding authentication (JWT tokens)
- Switching to
tokio::sync::RwLockfor better concurrency
Happy coding!
Top comments (0)