rustactix-webapibackendtutorial

Actix Web Tutorial: Build a REST API in Rust (Complete Guide 2026)

ยท9 min read

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 | sh

Project Setup

Create a new project:

cargo init actix-web-tutorial
cd actix-web-tutorial

Add 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::new takes a closure that returns an App. 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 implements Responder works.

Run it:

cargo run

Visit 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 run

Create 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/todos

Update 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_ID

Actix Web vs Axum

If you're deciding between the two most popular Rust web frameworks, here's the quick comparison:

Actix WebAxum
PerformanceMarginally faster in benchmarksVery close, both are fast
RoutingMacro-based (#[get("/")])Function-based (Router::new().route())
RuntimeOwn runtime (on Tokio)Pure Tokio
MiddlewareCustom middleware systemTower middleware ecosystem
ExtractorsFunction parametersFunction parameters
MaturityOlder, more battle-testedNewer, 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 = 30

Deploy:

rustfinity deploy

That'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

Get updated on the latest courses, features, tools, resources about Rust, and more!

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Check out our blog

Discover more insightful articles and stay up-to-date with the latest trends.

Subscribe to our newsletter

Get the latest updates and exclusive content delivered straight to your inbox.