How to Build a REST API with Actix-Web in Rust

Initialize a Rust project, add actix-web, define a handler, and run the server to build a REST API.

How to Build a REST API with Actix-Web in Rust

You have a service to build. Maybe it's a JSON endpoint that transforms data, or a backend for a frontend you're writing. You tried Python, but the latency is creeping up, or you just want the compiler to catch bugs before they hit production. You pick Actix-Web because the benchmarks look impressive. Now you need to turn that crate into a running server. Here's how you wire it up, why the async machinery works the way it does, and how to structure your routes so the code doesn't collapse as you add endpoints.

Concept: The kitchen manager

Actix-Web is an async web framework. It manages incoming HTTP connections, matches URLs to your code, and runs your handlers concurrently. Under the hood, it uses an actor model to distribute work across threads, which is why it scales well. For your code, you mostly interact with three pieces. HttpServer starts the listener and spawns workers. App holds your configuration and routes. Handlers are async functions that take request data and return a response.

Think of Actix-Web as a restaurant kitchen manager. The HttpServer is the building. The App is the menu and the kitchen layout. The route is the sign pointing customers to the right counter. The handler is the chef who actually cooks the dish. When a request comes in, the manager checks the menu, finds the right counter, and sends the order to the chef. The chef prepares the response and hands it back.

Minimal example: Hello world

Start with a project that responds to a single GET request. This example shows the core structure without any extra dependencies.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};

/// Handles GET requests to "/" and returns plain text.
async fn hello() -> impl Responder {
    // HttpResponse::Ok sets the status code to 200.
    // .body attaches the payload to the response.
    HttpResponse::Ok().body("Hello world!")
}

// The macro sets up the async runtime for Actix.
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // HttpServer::new takes a closure that builds the App.
    // The closure runs once per worker thread.
    HttpServer::new(|| {
        App::new()
            // web::get() creates a route for GET requests.
            // .to() links the route to the handler function.
            .route("/", web::get().to(hello))
    })
    // Bind to localhost on port 8080.
    // The ? operator propagates binding errors.
    .bind("127.0.0.1:8080")?
    // Start the server and wait for it to finish.
    .run()
    .await
}

Run cargo add actix-web to pull in the crate. Then run cargo run. The server starts and listens on port 8080. Hit http://127.0.0.1:8080 in your browser or with curl. You'll see Hello world!.

Convention aside: Actix-Web ships with its own #[actix_web::main] macro. This isn't just a convenience wrapper. It configures the Tokio runtime with settings optimized for Actix's actor system. Using #[tokio::main] directly can lead to subtle performance issues or panics because the runtime isn't set up the way Actix expects. Stick to the Actix macro.

Walkthrough: From socket to response

When you run the code, main executes asynchronously. HttpServer::new receives a closure. Actix spawns worker threads based on your CPU cores. Each thread calls your closure once to build an App instance. This means if you create state inside the closure, every worker gets its own copy. bind opens the TCP socket. If the port is taken, the ? operator returns the error and the program exits. run starts the event loop.

When a browser hits http://127.0.0.1:8080, Actix parses the HTTP request, matches the path / against your routes, and invokes hello. The handler returns a HttpResponse implementing Responder. Actix converts that into bytes and writes them back to the socket. The whole cycle happens without blocking other requests.

Realistic example: JSON and path parameters

Real APIs return structured data and accept parameters. This example adds JSON serialization and a path parameter to fetch a user by ID.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};

// Serialize derives the JSON serialization trait.
// Actix uses serde to convert structs to JSON automatically.
#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
}

/// Handles GET /users/{id} and returns a JSON user object.
async fn get_user(path: web::Path<u32>) -> impl Responder {
    // web::Path extracts and parses the URL parameter.
    // into_inner() retrieves the parsed value.
    let user_id = path.into_inner();

    // Construct the response data.
    let user = User {
        id: user_id,
        name: format!("User {}", user_id),
    };

    // .json() serializes the struct to JSON and sets
    // the Content-Type header to application/json.
    HttpResponse::Ok().json(user)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            // {id} is a path parameter placeholder.
            // Actix matches this pattern and extracts the value.
            .route("/users/{id}", web::get().to(get_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Add serde to your dependencies. Run the server and hit http://127.0.0.1:8080/users/42. You'll get {"id":42,"name":"User 42"}.

Convention aside: Actix-Web keeps its default dependency set small. JSON support isn't enabled by default. Add the json feature to actix-web in Cargo.toml (actix-web = { version = "4", features = ["json"] }) and include serde for the derive macros. This keeps your compile times down if you're only serving static files or plain text.

Sharing state across handlers

Real APIs need state. A database connection pool, a cache, or configuration. Actix-Web provides web::Data to share immutable state across handlers. web::Data is a thread-safe wrapper. You create the state, wrap it in web::Data::new, and attach it to the App. Handlers extract it by adding a parameter of type web::Data<State>.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use std::sync::Mutex;

struct AppState {
    // Mutex allows interior mutability for the shared state.
    // Actix handlers are async and share the Data across threads.
    counter: Mutex<u32>,
}

/// Increments the shared counter and returns the new value.
async fn increment(data: web::Data<AppState>) -> impl Responder {
    // Lock the mutex to modify the counter.
    // This is a blocking operation, but Mutex lock is fast.
    let mut counter = data.counter.lock().unwrap();
    *counter += 1;
    HttpResponse::Ok().body(format!("Count: {}", *counter))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Create the shared state.
    let state = AppState {
        counter: Mutex::new(0),
    };

    HttpServer::new(move || {
        // Clone the Data to move it into the closure.
        // web::Data uses Arc internally, so cloning is cheap.
        let data = web::Data::new(state);
        App::new()
            .app_data(data)
            .route("/increment", web::get().to(increment))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Convention aside: Cloning web::Data is cheap. It wraps an Arc. The community convention is to clone the data inside the HttpServer::new closure so each worker thread gets a handle to the same shared state. Don't put the Mutex inside the Data if you can avoid it. Use async locks like tokio::sync::Mutex for long operations, or keep the critical section tiny.

Pitfalls and compiler errors

Actix-Web is fast, but the async model introduces specific traps.

Blocking code freezes the runtime. Actix-Web runs on an async runtime. If your handler calls a blocking function like std::thread::sleep or a synchronous database driver, you freeze a worker thread. The runtime can't process other requests on that thread while it waits. Use async-compatible libraries, or wrap blocking calls in web::block.

Handlers return impl Responder. This is a Rust trait object shorthand. It lets you return different types as long as they implement Responder. If you try to return a type that doesn't implement Responder, the compiler rejects it with E0277 (trait bound not satisfied). Check that your return type is wrapped in HttpResponse or implements the trait.

The closure in HttpServer::new runs per worker. If you create a Vec or a DatabaseConnection inside the closure, each worker gets a separate instance. To share state across workers, create the state before HttpServer::new and use web::Data to inject it into the app. If you try to move a value into the closure without cloning or wrapping, you get E0382 (use of moved value). The closure is called multiple times, once per worker. You can't move the same value into multiple closures.

Don't block the async runtime. If you must call synchronous code, isolate it behind web::block or switch to an async driver.

Decision: Actix-Web versus alternatives

Rust has several web frameworks. Pick the one that matches your performance needs and your team's comfort level.

Use Actix-Web when you need raw throughput and low latency. It consistently ranks at the top of web framework benchmarks and handles massive concurrency with minimal overhead. Use Axum when you prefer an ergonomic API built on Tower and Hyper. Axum feels more like standard Rust middleware composition and integrates seamlessly with the Tokio ecosystem if you're already using Tower services. Use Warp when you want a filter-based, combinator style for routing. Warp lets you build routes by chaining small filters, which can be expressive for complex validation logic, though it may have higher memory usage than Actix. Use Poem when you want a lightweight framework with a focus on simplicity and a clean API similar to Go's Gin or Python's Flask. Poem is easier to learn for beginners but may not match Actix's peak performance.

Pick the framework that matches your team's comfort level and your performance requirements. Actix-Web wins on speed, but Axum often wins on developer experience.

Where to go next