How to Set Up Routing in Actix-Web

Set up Actix-Web routing by defining handlers, chaining them to paths in an App, and running the server with HttpServer.

The missing link between URL and code

You wrote a function that returns a JSON response. You run the server. You open localhost:8080/users. The browser gives you a 404. The server is running, the function exists, but the connection between the URL and the code is missing. Routing is that connection. It tells the server which function handles which request. Without routes, your server is a black box that accepts connections but has no idea what to do with them.

Routing as a call center

Think of your server as a busy call center. Requests are incoming calls. Routing is the operator who listens to the first few digits of the number and transfers the call to the right agent. If the number doesn't match any pattern, the operator plays a busy signal or a default message.

In Actix-Web, the App struct is the call center. Routes are the transfer rules. Handlers are the agents. The router matches the incoming path and HTTP method against your rules. When it finds a match, it calls the handler and sends the response back. If no rule matches, Actix returns a 404 by default.

The minimal setup

Here is the smallest working server with a route. It binds one path to one handler.

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

/// Returns a simple greeting for the root path.
async fn index() -> impl Responder {
    HttpResponse::Ok().body("Hello from Actix!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // HttpServer creates the actual server instance.
    // The closure runs once per worker thread.
    HttpServer::new(|| {
        // App::new() starts the routing configuration.
        // .route() binds a specific path and method to a handler.
        App::new()
            .route("/", web::get().to(index))
    })
    // .bind() attaches the server to an IP and port.
    // The ? operator propagates binding errors.
    .bind("127.0.0.1:8080")?
    // .run() starts the event loop.
    .run()
    .await
}

What happens here

HttpServer::new takes a closure. This closure is the factory. Actix spawns multiple worker threads to handle requests. Each thread calls the closure to build its own App. This means the closure runs once per worker, not once for the whole server. If you put heavy initialization inside the closure, you pay that cost multiple times. Keep the closure light.

App::new creates the router. .route("/", web::get().to(index)) adds a rule. web::get() specifies the HTTP method. .to(index) points to the handler function. The handler must return something that implements Responder. HttpResponse implements Responder, so this compiles.

.bind("127.0.0.1:8080") attaches the server to the loopback address on port 8080. If the port is already in use, this returns an error. The ? operator propagates that error to main, which exits the process. .run().await starts the event loop and blocks until you stop the server.

Treat the closure as a factory function. Build the app, don't do the work.

Paths, methods, and resources

Real applications need more than one route. You need to handle different paths, different methods, and parameters.

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

/// Extracts a user ID from the URL path.
async fn get_user(user_id: web::Path<u32>) -> impl Responder {
    HttpResponse::Ok().body(format!("User {}", user_id))
}

/// Handles POST requests to create a user.
async fn create_user() -> impl Responder {
    HttpResponse::Created().body("User created")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            // .route() works for single handlers.
            .route("/users/{id}", web::get().to(get_user))
            // .service() is better for grouping or complex resources.
            .service(
                web::resource("/users")
                    .route(web::post().to(create_user))
            )
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Path parameters

The route /users/{id} contains a parameter. The handler get_user takes web::Path<u32>. Actix extracts the id from the URL, parses it as a u32, and passes it to the handler. If the URL contains /users/abc, parsing fails and Actix returns a 400 Bad Request automatically. You don't need to write error handling for invalid types.

Convention is to use web::Path<T> for path parameters. Don't parse strings manually. Actix handles the extraction and validation. If you need multiple parameters, use a tuple or a struct that implements Deserialize.

Resources vs routes

.route() attaches a single handler to a path. .service() attaches a resource or scope. web::resource("/users") creates a resource object. You can chain multiple .route() calls on a resource to support different methods on the same path. This groups related routes logically.

Convention is to use web::resource when a path supports multiple methods. It makes the routing table easier to read. Use web::scope when you want to group routes under a common prefix, like /api/v1/, and share middleware across them.

Route order rarely matters for static paths. Actix uses a radix tree to match routes, so it finds the best match regardless of definition order. Dynamic paths can shadow each other if they overlap. Test your routes to ensure the expected handler fires.

Sharing state with web::Data

Handlers are stateless by default. Each request runs in isolation. If you need to share data across handlers, like a database pool or a configuration object, you use web::Data.

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

/// Shared state structure.
struct AppState {
    counter: Mutex<u32>,
}

/// Handler that reads shared state.
async fn get_count(data: web::Data<AppState>) -> impl Responder {
    let counter = data.lock().unwrap();
    HttpResponse::Ok().body(format!("Count: {}", *counter))
}

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

    HttpServer::new(move || {
        // Move state into the closure.
        App::new()
            // .app_data() registers state for extraction.
            // .clone() is cheap; it copies the pointer, not the data.
            .app_data(state.clone())
            .route("/", web::get().to(get_count))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

How web::Data works

web::Data<T> wraps an Arc<T>. Arc is an atomic reference counted pointer. Cloning web::Data increments a counter and copies the pointer. It does not copy the underlying data. This makes sharing state cheap.

The handler get_count takes web::Data<AppState> as an argument. Actix extracts the data from the app context and passes it to the handler. This is called dependency injection. You don't need to pass state manually. Actix handles the wiring.

Convention is to clone web::Data when registering it with .app_data(). The clone is a pointer bump. It's safe and fast. Don't worry about performance here. The overhead is negligible compared to network I/O.

If your state type doesn't implement Send + Sync, the compiler rejects you. Actix runs on multiple threads. Shared state must be thread-safe. Use Mutex, RwLock, or atomic types inside your state struct.

Don't fight the borrow checker here. Use web::Data for shared state.

Pitfalls and compiler errors

Wrong return type

If your handler returns a String, the compiler rejects you with E0277 (trait bound not satisfied). Actix expects something that implements Responder. String does not implement Responder directly. Wrap it in HttpResponse::Ok().body(string) or return impl Responder and use HttpResponse.

Blocking code in handlers

Actix runs on an async executor. If you block the thread with a CPU-heavy loop or a synchronous I/O call, you stall the entire worker. Actix detects blocking code and logs a warning. Use actix_web::web::block to run blocking code in a separate thread pool.

use actix_web::web;

async fn heavy_handler() -> impl Responder {
    // Move blocking work to a thread pool.
    let result = web::block(|| {
        // Synchronous, blocking code here.
        expensive_computation()
    })
    .await
    .unwrap();

    HttpResponse::Ok().body(result)
}

Convention is to keep handlers thin. Move logic to services or background tasks. Handlers should extract data, call services, and return responses.

State lifetime errors

If you try to capture a non-Send value in the HttpServer closure, you'll hit a thread safety error. The closure must be Send because it moves across threads. Ensure all captured values are thread-safe. Use web::Data for shared state. Don't capture references to stack variables.

Trust the borrow checker. It usually has a point.

Decision matrix

Use .route() when you have a single handler for a specific path and method. Use .service() when you want to attach a pre-configured resource or a nested scope to the app. Use web::resource() when a single path supports multiple methods, like GET and POST on /users. Use web::scope() when you want to group routes under a common prefix, like /api/v1/, and share middleware across them. Reach for web::Path when you need to extract parameters from the URL structure. Reach for web::Data when you need to share state across handlers or middleware. Reach for web::block when you must call synchronous libraries that cannot be made async.

Counter-intuitive but true: the more you pack into the HttpServer closure, the harder it becomes to test your routes. Keep the closure minimal. Build the app, register the routes, and return.

Where to go next