Actix Web Tutorial: Build a REST API in Rust (Complete Guide 2026)
Actix Web Tutorial: Build a REST API in Rust
Actix Web is one of the fastest web frameworks in any language. It consistently tops the TechEmpower benchmarks and has been the go-to choice for Rust developers who need raw performance without sacrificing ergonomics.
This tutorial takes you from zero to a working CRUD API. We'll cover routing, extractors, state management, error handling, middleware, and deployment. Every code example compiles and runs against Actix Web 4 (the latest stable release).
If you're coming from Axum, you'll notice Actix Web takes a different approach - it uses a macro-based routing system and its own async runtime instead of Tokio's. The result is slightly more boilerplate but often better raw throughput.
Prerequisites
You need Rust installed. If you don't have it yet:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shProject Setup
Create a new project:
cargo init actix-web-tutorial
cd actix-web-tutorialAdd the dependencies to Cargo.toml:
[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
env_logger = "0.11"
log = "0.4"- actix-web - the web framework itself
- serde - serialization and deserialization for JSON
- uuid - generating unique IDs for our todo items
- env_logger and log - request logging
Hello World
The simplest Actix Web server:
use actix_web::{get, App, HttpServer, Responder};
#[get("/")]
async fn hello() -> impl Responder {
"Hello, world!"
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(hello))
.bind(("127.0.0.1", 8080))?
.run()
.await
}A few things to notice:
#[get("/")]is a route macro. It marks the function as a handler for GET requests to/.#[actix_web::main]sets up the Actix runtime. This replaces#[tokio::main]- Actix has its own async runtime built on top of Tokio.HttpServer::newtakes a closure that returns anApp. This closure runs once per worker thread, which is why state sharing needs some thought (more on that later).- Handlers return
impl Responder. Strings,HttpResponse, JSON - anything that implementsResponderworks.
Run it:
cargo runVisit http://127.0.0.1:8080 and you'll see "Hello, world!".
Routing
Actix Web gives you two ways to define routes: attribute macros and manual configuration.
Attribute Macros
The cleanest approach. Decorate your handler functions directly:
use actix_web::{get, post, put, delete, Responder};
#[get("/users")]
async fn list_users() -> impl Responder {
"List all users"
}
#[post("/users")]
async fn create_user() -> impl Responder {
"Create a user"
}
#[put("/users/{id}")]
async fn update_user() -> impl Responder {
"Update a user"
}
#[delete("/users/{id}")]
async fn delete_user() -> impl Responder {
"Delete a user"
}Register them with .service():
App::new()
.service(list_users)
.service(create_user)
.service(update_user)
.service(delete_user)Manual Routes
If you prefer configuring routes without macros:
use actix_web::web;
App::new()
.route("/users", web::get().to(list_users))
.route("/users", web::post().to(create_user))With manual routes, your handler functions don't need the macro attributes. Both approaches work fine - macros are more common in practice.
Extractors
Extractors pull data out of incoming requests. They're type-safe - if the data doesn't match what you asked for, Actix Web returns a 400 error automatically.
Path Parameters
use actix_web::{get, web, Responder};
#[get("/users/{id}")]
async fn get_user(path: web::Path<u32>) -> impl Responder {
let user_id = path.into_inner();
format!("User ID: {user_id}")
}For multiple parameters, use a tuple:
#[get("/posts/{user_id}/{post_id}")]
async fn get_post(path: web::Path<(u32, u32)>) -> impl Responder {
let (user_id, post_id) = path.into_inner();
format!("User {user_id}, Post {post_id}")
}Query Parameters
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
#[get("/items")]
async fn list_items(query: web::Query<Pagination>) -> impl Responder {
let page = query.page.unwrap_or(1);
let per_page = query.per_page.unwrap_or(20);
format!("Page {page}, showing {per_page} items")
}A request to /items?page=2&per_page=10 extracts the values automatically. Since both fields are Option, the query string is optional.
JSON Body
use actix_web::{post, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
struct CreateUser {
name: String,
email: String,
}
#[post("/users")]
async fn create_user(body: web::Json<CreateUser>) -> impl Responder {
HttpResponse::Created().json(serde_json::json!({
"message": format!("Created user: {}", body.name),
"email": body.email,
}))
}If the request body isn't valid JSON or doesn't match the struct, Actix Web returns a 400 error with details about what went wrong.
Combining Extractors
You can use multiple extractors in a single handler:
#[put("/users/{id}")]
async fn update_user(
path: web::Path<u32>,
body: web::Json<CreateUser>,
) -> impl Responder {
let user_id = path.into_inner();
HttpResponse::Ok().json(serde_json::json!({
"id": user_id,
"name": body.name,
"email": body.email,
}))
}Actix Web extracts each parameter independently. Path from the URL, JSON from the body - they don't interfere with each other.
Application State
Most APIs need shared state - a database pool, configuration, a cache. Actix Web handles this with web::Data.
use std::sync::Mutex;
struct AppState {
visit_count: Mutex<u64>,
}
#[get("/visits")]
async fn visits(data: web::Data<AppState>) -> impl Responder {
let mut count = data.visit_count.lock().unwrap();
*count += 1;
format!("Total visits: {count}")
}Register state in main:
let data = web::Data::new(AppState {
visit_count: Mutex::new(0),
});
HttpServer::new(move || {
App::new()
.app_data(data.clone())
.service(visits)
})web::Data wraps your state in an Arc, so cloning it is cheap. The Mutex is needed because Actix Web runs multiple worker threads and they all share this state.
For read-heavy workloads, consider RwLock instead of Mutex. For database pools, most pool types (like sqlx::PgPool) are already thread-safe and don't need a Mutex at all.
Error Handling
Actix Web handlers can return Result<HttpResponse, Error>. The framework converts errors into HTTP responses automatically.
Quick Errors
For simple cases, use the built-in error constructors:
use actix_web::error::ErrorBadRequest;
#[post("/todos")]
async fn create_todo(
body: web::Json<CreateTodo>,
) -> Result<HttpResponse, actix_web::Error> {
if body.title.trim().is_empty() {
return Err(ErrorBadRequest("Title cannot be empty"));
}
Ok(HttpResponse::Created().json(serde_json::json!({
"title": body.title,
})))
}Actix Web provides ErrorBadRequest, ErrorNotFound, ErrorInternalServerError, and many others.
Custom Error Types
For more control, implement ResponseError on your own type:
use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct ApiError {
error: String,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.error)
}
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
HttpResponse::BadRequest().json(self)
}
}Now you can return Result<HttpResponse, ApiError> from handlers, and errors automatically become JSON responses:
{ "error": "Todo with id 'abc' not found" }You can also override status_code() on ResponseError if you need different status codes for different error variants. An enum with #[derive(Debug)] works well for that.
Middleware
Actix Web has built-in middleware and lets you add your own.
Built-in Logger
use actix_web::middleware;
App::new()
.wrap(middleware::Logger::default())
.service(hello)This logs every request in Apache-like format. Initialize the logger in main:
env_logger::init_from_env(
env_logger::Env::default().default_filter_or("info")
);CORS
Add the actix-cors crate to Cargo.toml:
actix-cors = "0.7"Then configure it:
use actix_cors::Cors;
App::new()
.wrap(
Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.max_age(3600),
)
.service(hello)For production, replace allow_any_origin() with specific origins.
Organizing Routes with Scopes
As your API grows, you'll want to group related routes. Scopes let you add a shared prefix:
use actix_web::web;
App::new()
.service(
web::scope("/api/v1")
.service(
web::scope("/todos")
.service(list_todos)
.service(get_todo)
.service(create_todo)
.service(update_todo)
.service(delete_todo),
)
)
.service(health)Now all todo routes live under /api/v1/todos/. The health check stays at /health since it's outside the scope.
Configure Function
For cleaner organization, extract route configuration into functions:
fn todo_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/todos")
.service(list_todos)
.service(get_todo)
.service(create_todo)
.service(update_todo)
.service(delete_todo),
);
}
App::new()
.configure(todo_routes)
.service(health)This pattern is great for splitting routes across multiple files as your codebase grows.
Putting It All Together
All the pieces above - routing, extractors, state, error handling, middleware, scopes - compose into a working API. The main function wires everything together:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(
env_logger::Env::default().default_filter_or("info"),
);
let data = web::Data::new(AppState {
todos: Mutex::new(Vec::new()),
});
HttpServer::new(move || {
App::new()
.app_data(data.clone())
.wrap(middleware::Logger::default())
.configure(todo_routes)
.service(health)
})
.bind("0.0.0.0:8080")?
.run()
.await
}The handlers use web::Data for shared state, web::Path and web::Json for extractors, and return Result<HttpResponse, ApiError> for error handling. The todo_routes function groups everything under /todos using scopes, and the Logger middleware wraps all requests.
Testing with curl
Start the server:
cargo runCreate a todo:
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn Actix Web"}'List all todos:
curl http://localhost:8080/todosUpdate a todo (replace the ID with the one you got back):
curl -X PUT http://localhost:8080/todos/YOUR_TODO_ID \
-H "Content-Type: application/json" \
-d '{"completed": true}'Delete a todo:
curl -X DELETE http://localhost:8080/todos/YOUR_TODO_IDActix Web vs Axum
If you're deciding between the two most popular Rust web frameworks, here's the quick comparison:
| Actix Web | Axum | |
|---|---|---|
| Performance | Marginally faster in benchmarks | Very close, both are fast |
| Routing | Macro-based (#[get("/")]) | Function-based (Router::new().route()) |
| Runtime | Own runtime (on Tokio) | Pure Tokio |
| Middleware | Custom middleware system | Tower middleware ecosystem |
| Extractors | Function parameters | Function parameters |
| Maturity | Older, more battle-tested | Newer, backed by Tokio team |
If you need the absolute best throughput and don't mind the macro-based approach, Actix Web is the right pick. If you want tighter Tokio integration and access to the Tower middleware ecosystem, go with Axum.
Both are excellent. You won't regret either choice.
Deploy to Rustfinity Cloud
Your API is working locally. Let's get it live.
The code already reads the PORT environment variable, which is required for Rustfinity Cloud:
let port = std::env::var("PORT")
.unwrap_or_else(|_| "8080".to_string());
let addr = format!("0.0.0.0:{port}");Add a Rustfinity.toml to the project root:
[app]
name = "actix-web-tutorial"
[build]
command = "cargo build --release"
binary = "target/release/actix-web-tutorial"
[health]
path = "/health"
interval = 30Deploy:
rustfinity deployThat's it. Your Actix Web API is live on the internet with health checks, zero-downtime deploys, and automatic restarts. See the deployment guide for more details on environment variables, logs, and rollbacks.
What to Learn Next
- Axum Tutorial - if you want to compare with the other popular Rust framework
- Best Rust Web Frameworks - overview and benchmarks of all the major options
- Actix Web Documentation - the official docs cover WebSockets, static files, testing, and more
- Rustfinity Challenges - practice Rust fundamentals with hands-on coding challenges