What are the trade-offs between axum::Router::merge and nest for combining route handlers?

merge combines routers at the same path level, flattening their routes into a single router without introducing a path prefix, while nest mounts a router under a specific path prefix, requiring all nested routes to be accessed through that prefix. The fundamental distinction is path handling: merge is a union operation that preserves original paths, whereas nest is a hierarchical composition that prefixes all routes.

Basic merge Usage

use axum::{Router, routing::get};
 
fn basic_merge() {
    let user_routes = Router::new()
        .route("/users", get(list_users))
        .route("/users/:id", get(get_user));
    
    let product_routes = Router::new()
        .route("/products", get(list_products))
        .route("/products/:id", get(get_product));
    
    // merge combines routes at the same level
    let app = Router::new()
        .merge(user_routes)
        .merge(product_routes);
    
    // Routes are now:
    // GET /users
    // GET /users/:id
    // GET /products
    // GET /products/:id
}
 
async fn list_users() {}
async fn get_user() {}
async fn list_products() {}
async fn get_product() {}

merge combines multiple routers into one, preserving all original paths.

Basic nest Usage

use axum::{Router, routing::get};
 
fn basic_nest() {
    let user_routes = Router::new()
        .route("/", get(list_users))           // Note: relative paths
        .route("/:id", get(get_user));
    
    let product_routes = Router::new()
        .route("/", get(list_products))
        .route("/:id", get(get_product));
    
    // nest mounts routers under a prefix
    let app = Router::new()
        .nest("/users", user_routes)
        .nest("/products", product_routes);
    
    // Routes are now:
    // GET /users         (from /users + /)
    // GET /users/:id     (from /users + /:id)
    // GET /products      (from /products + /)
    // GET /products/:id  (from /products + /:id)
}
 
async fn list_users() {}
async fn get_user() {}
async fn list_products() {}
async fn get_product() {}

nest prefixes all nested routes with the specified path.

Path Structure Differences

use axum::{Router, routing::get};
 
fn path_structure() {
    // With merge: define full paths in each router
    let api_v1 = Router::new()
        .route("/api/v1/users", get(users_v1))
        .route("/api/v1/products", get(products_v1));
    
    let api_v2 = Router::new()
        .route("/api/v2/users", get(users_v2))
        .route("/api/v2/products", get(products_v2));
    
    let app = Router::new()
        .merge(api_v1)
        .merge(api_v2);
    
    // With nest: define relative paths, prefix at mount
    let api_routes_v1 = Router::new()
        .route("/users", get(users_v1))
        .route("/products", get(products_v1));
    
    let api_routes_v2 = Router::new()
        .route("/users", get(users_v2))
        .route("/products", get(products_v2));
    
    let app = Router::new()
        .nest("/api/v1", api_routes_v1)
        .nest("/api/v2", api_routes_v2);
    
    // Both produce same routes, but nest is more modular
}
 
async fn users_v1() {}
async fn users_v2() {}
async fn products_v1() {}
async fn products_v2() {}

merge requires full paths in each router; nest uses relative paths with prefix.

Handler Access to Path Parameters

use axum::{Router, routing::get, extract::Path};
 
fn path_parameters() {
    // merge: handlers define their own full paths
    let routes = Router::new()
        .route("/users/:id", get(get_user))
        .merge(Router::new().route("/posts/:post_id", get(get_post)));
    // Handlers see /users/:id and /posts/:post_id directly
    
    // nest: prefix path is stripped before reaching handler
    let nested_routes = Router::new()
        .route("/:id", get(get_user_nested));
    
    let app = Router::new()
        .nest("/users", nested_routes);
    // Handler sees only :id, not /users/:id
}
 
async fn get_user(Path(id): Path<String>) {
    // Full path parameter access
}
 
async fn get_post(Path(post_id): Path<String>) {
    // Direct access to post_id
}
 
async fn get_user_nested(Path(id): Path<String>) {
    // Only sees :id from the nested router's path
    // The /users prefix is already matched and stripped
}

With nest, the prefix path is matched before the nested router sees the request.

Middleware Application

use axum::{Router, routing::get, middleware};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
 
fn middleware_application() {
    let user_routes = Router::new()
        .route("/users", get(list_users));
    
    let admin_routes = Router::new()
        .route("/admin/settings", get(get_settings));
    
    // merge: apply middleware to combined router
    let app = Router::new()
        .merge(user_routes)
        .merge(admin_routes)
        .layer(TraceLayer::new_for_http());
    // TraceLayer applies to ALL routes
    
    // nest: apply middleware to specific nested router
    let admin_with_auth = Router::new()
        .route("/settings", get(get_settings))
        .layer(TraceLayer::new_for_http());
    // This layer only applies to /admin/* routes
    
    let app = Router::new()
        .nest("/admin", admin_with_auth)
        .route("/users", get(list_users));
    // /users has no TraceLayer, /admin/settings does
}
 
async fn list_users() {}
async fn get_settings() {}

nest enables scoped middleware; merge requires middleware on the combined router.

Route Organization Patterns

use axum::{Router, routing::{get, post, put, delete}};
 
fn organization_patterns() {
    // Pattern 1: Domain-based routers merged together
    let users_router = Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/:id", get(get_user).put(update_user).delete(delete_user));
    
    let products_router = Router::new()
        .route("/products", get(list_products).post(create_product))
        .route("/products/:id", get(get_product).put(update_product));
    
    let orders_router = Router::new()
        .route("/orders", get(list_orders).post(create_order));
    
    let app = Router::new()
        .merge(users_router)
        .merge(products_router)
        .merge(orders_router);
    // Flat structure, all routes at top level
    
    // Pattern 2: Hierarchical with nest
    let users_api = Router::new()
        .route("/", get(list_users).post(create_user))
        .route("/:id", get(get_user).put(update_user).delete(delete_user));
    
    let products_api = Router::new()
        .route("/", get(list_products).post(create_product))
        .route("/:id", get(get_product).put(update_product));
    
    let orders_api = Router::new()
        .route("/", get(list_orders).post(create_order));
    
    let api_routes = Router::new()
        .nest("/users", users_api)
        .nest("/products", products_api)
        .nest("/orders", orders_api);
    
    let app = Router::new()
        .nest("/api", api_routes);
    // Hierarchical: /api/users, /api/products, /api/orders
}

merge creates flat organizations; nest creates hierarchical structures.

Fallback Handlers

use axum::{Router, routing::get, handler::Handler};
 
fn fallback_handlers() {
    // merge: single fallback for entire router
    let routes = Router::new()
        .route("/users", get(list_users));
    
    let app = Router::new()
        .merge(routes)
        .fallback(fallback_handler);
    // All unmatched routes go to fallback_handler
    
    // nest: fallback only applies to nested router's scope
    let nested_routes = Router::new()
        .route("/users", get(list_users))
        .fallback(nested_fallback);
    // This fallback only handles /api/* unmatched routes
    
    let app = Router::new()
        .nest("/api", nested_routes)
        .route("/other", get(other_handler))
        .fallback(root_fallback);
    // /api/unmatched -> nested_fallback
    // /unmatched -> root_fallback
}
 
async fn list_users() {}
async fn fallback_handler() {}
async fn nested_fallback() {}
async fn root_fallback() {}
async fn other_handler() {}

nest allows scoped fallback handlers; merge uses a single router-wide fallback.

State and Extensions

use axum::{Router, routing::get, extract::State};
 
#[derive(Clone)]
struct AppState {
    db: Db,
}
 
#[derive(Clone)]
struct AdminState {
    db: Db,
    admin_secret: String,
}
 
fn state_handling() {
    let state = AppState { db: Db::new() };
    let admin_state = AdminState { 
        db: Db::new(), 
        admin_secret: "secret".to_string() 
    };
    
    // merge: single state type for all routes
    let user_routes = Router::new()
        .route("/users", get(list_users));
    
    let admin_routes = Router::new()
        .route("/admin", get(admin_panel));
    
    let app = Router::new()
        .merge(user_routes)
        .merge(admin_routes)
        .with_state(state);
    // All handlers must use AppState
    
    // nest: can have different state for nested router
    let admin_routes = Router::new()
        .route("/panel", get(admin_panel))
        .with_state(admin_state);
    
    let app = Router::new()
        .nest("/admin", admin_routes)
        .route("/users", get(list_users))
        .with_state(state);
    // /admin/* uses AdminState
    // /users uses AppState
}
 
async fn list_users(State(state): State<AppState>) {}
async fn admin_panel(State(state): State<AdminState>) {}
 
struct Db;
impl Db {
    fn new() -> Self { Self }
}

nest allows different state types per nested router; merge requires a single state type.

Combining merge and nest

use axum::{Router, routing::get};
 
fn combined_approach() {
    let v1_routes = Router::new()
        .route("/users", get(users_v1))
        .route("/products", get(products_v1));
    
    let v2_routes = Router::new()
        .route("/users", get(users_v2))
        .route("/products", get(products_v2))
        .route("/orders", get(orders_v2));
    
    // Use merge for same-level routes
    let api_routes = Router::new()
        .nest("/v1", v1_routes)
        .nest("/v2", v2_routes);
    
    // Use nest for hierarchical prefix
    let app = Router::new()
        .nest("/api", api_routes)
        .route("/health", get(health));
    
    // Routes:
    // GET /api/v1/users
    // GET /api/v1/products
    // GET /api/v2/users
    // GET /api/v2/products
    // GET /api/v2/orders
    // GET /health
}
 
async fn users_v1() {}
async fn users_v2() {}
async fn products_v1() {}
async fn products_v2() {}
async fn orders_v2() {}
async fn health() {}

Use both together: nest for hierarchical prefixes, merge for combining routers at the same level.

Route Conflicts

use axum::{Router, routing::get};
 
fn route_conflicts() {
    // merge: Conflicts possible, last one wins
    let router1 = Router::new()
        .route("/users", get(users_v1));
    
    let router2 = Router::new()
        .route("/users", get(users_v2));
    
    let app = Router::new()
        .merge(router1)
        .merge(router2);
    // /users -> users_v2 (later router wins)
    
    // nest: Conflicts isolated to nested scope
    let v1 = Router::new()
        .route("/users", get(users_v1));
    
    let v2 = Router::new()
        .route("/users", get(users_v2));
    
    let app = Router::new()
        .nest("/v1", v1)
        .nest("/v2", v2);
    // /v1/users -> users_v1
    // /v2/users -> users_v2
    // No conflict - different paths
}
 
async fn users_v1() {}
async fn users_v2() {}

merge can have route conflicts; nest isolates routes under different prefixes.

Performance Characteristics

use axum::{Router, routing::get};
 
fn performance() {
    // merge: Flat routing table
    // O(1) route lookup in the combined trie
    let app = Router::new()
        .merge(router_a())
        .merge(router_b())
        .merge(router_c());
    // Single routing table for all routes
    
    // nest: Hierarchical routing
    // Lookup traverses prefix, then nested router's trie
    let app = Router::new()
        .nest("/api", api_routes())
        .nest("/admin", admin_routes());
    // First match /api, then routes within api_routes
    
    // Performance difference is typically negligible
    // Both use efficient trie-based matching
    
    // merge is slightly faster for very large numbers of routes
    // nest provides cleaner organization with minimal overhead
}
 
fn router_a() -> Router { Router::new() }
fn router_b() -> Router { Router::new() }
fn router_c() -> Router { Router::new() }
fn api_routes() -> Router { Router::new() }
fn admin_routes() -> Router { Router::new() }

Both use efficient routing; merge is marginally faster for very large route counts.

Testing and Modularity

use axum::{Router, routing::get};
 
// With nest: each module exports a router with relative paths
mod users {
    use axum::{Router, routing::get};
    
    pub fn routes() -> Router {
        Router::new()
            .route("/", get(list))
            .route("/:id", get(get_user))
    }
    
    async fn list() {}
    async fn get_user() {}
}
 
mod products {
    use axum::{Router, routing::get};
    
    pub fn routes() -> Router {
        Router::new()
            .route("/", get(list))
            .route("/:id", get(get_product))
    }
    
    async fn list() {}
    async fn get_product() {}
}
 
fn modular_organization() {
    let app = Router::new()
        .nest("/users", users::routes())
        .nest("/products", products::routes());
    
    // Each module defines routes relative to its mount point
    // Easy to test each router in isolation
    // Can change mount point without modifying module
}

nest enables better modularity; each module's router is self-contained with relative paths.

When to Use Each

use axum::{Router, routing::get};
 
fn usage_guidelines() {
    // Use merge when:
    // 1. Combining routers at the same path level
    // 2. Routes are independent and should be flattened
    // 3. Single state type for all routes
    // 4. Simple route organization without hierarchy
    
    let public_routes = Router::new()
        .route("/health", get(health))
        .route("/metrics", get(metrics));
    
    let api_routes = Router::new()
        .route("/api/users", get(users))
        .route("/api/products", get(products));
    
    let app = Router::new()
        .merge(public_routes)
        .merge(api_routes);
    
    // Use nest when:
    // 1. Hierarchical API structure (/api/v1, /api/v2)
    // 2. Different middleware for different sections
    // 3. Different state types for different sections
    // 4. Modular code organization
    // 5. Versioned APIs
    
    let v1 = Router::new()
        .route("/users", get(users_v1))
        .route("/products", get(products_v1));
    
    let v2 = Router::new()
        .route("/users", get(users_v2))
        .route("/products", get(products_v2));
    
    let app = Router::new()
        .nest("/api/v1", v1)
        .nest("/api/v2", v2);
}
 
async fn health() {}
async fn metrics() {}
async fn users() {}
async fn products() {}
async fn users_v1() {}
async fn products_v1() {}
async fn users_v2() {}
async fn products_v2() {}

Choose based on organization needs and whether you want hierarchical or flat routing.

Synthesis

Quick comparison:

Aspect merge nest
Path handling Preserves original paths Prefixes all routes
Path in handlers Full path Relative (prefix stripped)
Middleware scope Entire router Per nested router
State types Single type Different types per nest
Fallback Router-wide Scoped per nest
Organization Flat Hierarchical
Modularity Less isolated Highly modular

Decision matrix:

use axum::Router;
 
fn choose_approach() {
    // FLAT: Use merge
    // - Simple API with few routes
    // - All routes share same middleware/state
    // - Routes defined in different modules but same level
    // - Example: public routes + api routes at root
    
    // HIERARCHICAL: Use nest
    // - API versioning (/v1, /v2)
    // - Admin area with separate auth middleware
    // - Different state types for different areas
    // - Modular codebase with isolated routers
    // - Example: /api/v1/users, /admin/settings
    
    // HYBRID: Both together
    // - nest for hierarchy (versions, areas)
    // - merge for same-level routers (public + health)
}

Key insight: The choice between merge and nest is fundamentally about path ownership and scope. merge is a union operation—it says "combine these routes at the same level, treating them as if they were defined together." This is useful for combining independently developed routers that should appear as one flat API. nest is a composition operation—it says "mount this router under this prefix, with its own scope for middleware, state, and fallbacks." This is essential for hierarchical APIs, versioned endpoints, or separating areas like public/ authenticated/ admin with different concerns. The critical difference in practice is that with nest, the nested router's handlers receive paths relative to the mount point, and the prefix is matched before the nested router sees the request. This enables modularity: a user routes module can define / and /:id without knowing it will be mounted at /api/v1/users. With merge, every router must be aware of the full paths, making it harder to compose independently. Use nest when you want encapsulation and hierarchy; use merge when you want a flat union of routes at the same level.