Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Web Applications and Routing

The ntex::web module provides everything needed to build an HTTP application: routing, request extractors, responses, middleware, and testing tools.

web::App is where an application comes together. You add routes, resources, scopes, middleware, filters, and fallback services, then ntex builds them into the service that handles incoming requests.

The server evaluates the application factory independently in each worker:

use ntex::web::{self, HttpResponse};

#[ntex::main]
async fn main() -> std::io::Result<()> {
    web::server(async |_| {
        web::App::new()
            .route(
                "/",
                web::get().to(async || HttpResponse::Ok().body("Hello")),
            )
    })
    .bind("127.0.0.1:8080", ntex::SharedCfg::default())?
    .run()
    .await
}

The factory returns an application factory rather than a running service. HttpService uses it to create the application service for each connection, so application services are never shared between workers. A worker can invoke the outer factory again if its service has to be recreated, so keep factory setup repeatable rather than relying on it to run exactly once.

To learn more about workers, server configuration, and application state, see Server and Worker and request state.

Handlers

The quickest way to register a handler is App::route():

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new()
    .route("/users", web::get().to(async || HttpResponse::Ok()))
    .route(
        "/users",
        web::post().to(async || HttpResponse::Created()),
    );

A handler takes request extractors that implement FromRequest and returns a value that implements Responder.

Each call to App::route() creates a separate Resource containing one Route. The route’s method and custom guards become resource guards. This means you can register the same path several times with different guards. It also means that when those guards fail, ntex skips that resource and uses the application fallback, which returns 404 Not Found by default.

When several routes belong to the same path, group them with App::service() and web::resource(). A resource can also have its own name, middleware, filters, and fallback:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new().service(
    web::resource("/users/{id}")
        .name("user")
        .route(web::get().to(async || HttpResponse::Ok()))
        .route(web::delete().to(async || HttpResponse::NoContent())),
);

Resource::to() adds a route without a method guard, so it accepts every HTTP method:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new().service(
    web::resource("/health").to(async || HttpResponse::Ok()),
);

ntex also provides attribute macros for common routes. The generated service can be passed directly to App::service():

use ntex::web::{self, HttpResponse};

#[web::get("/health")]
async fn health() -> HttpResponse {
    HttpResponse::Ok().into()
}

let app = web::App::<()>::new().service(health);

Method-specific route helpers include get, post, put, delete, patch, head, and query. Routes for other methods are created with web::method(), for example web::method(Method::OPTIONS).

Method matching is exact. A GET route does not also register HEAD, and ntex does not add an OPTIONS route for you. Register those methods explicitly when clients need them.

Choosing a Registration Style

For a small endpoint, App::route() is usually the clearest choice. It keeps the path, method, and handler together:

use ntex::web::{self, App};

let app = App::default()
    .route("/health", web::get().to(async || "ready"));

Use an explicit Resource when one path has several methods, needs a name, or has its own middleware, filter, or fallback. Use a Scope when several resources share a path prefix or policy. This distinction is more than style: an App::route() method mismatch continues through the application router, while a route mismatch inside a selected Resource reaches that resource’s fallback.

web::service() is the lower-level option for code that already implements an ntex service over WebRequest. Most handler-based applications do not need it.

Builder Order

App is configured in two phases. Application-wide settings come first: middleware(), filter(), case_insensitive_routing(), with_config(), and external_resource(). The first call to route(), service(), configure(), or default_service() switches the builder to service registration. After that, only route(), service(), and default_service() are available.

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new()
    // Application-wide settings
    .case_insensitive_routing()
    .external_resource("docs", "https://docs.example.com/{page}")
    // Service registration
    .route("/", web::get().to(async || HttpResponse::Ok()))
    .default_service(web::to(async || HttpResponse::NotFound()));

Calling a setting such as external_resource() after route() does not compile. Scope follows the same rule for its settings: guard(), middleware(), filter(), and case_insensitive_routing().

How Routing Works

A request moves through the application in several stages:

  1. Application middleware wraps the application service.
  2. Application filters process the incoming WebRequest.
  3. The application router finds a Resource whose path and resource guards match.
  4. Resource middleware wraps the selected resource’s filter and routes.
  5. The resource filter processes the request.
  6. The resource checks its routes in registration order. A route matches only if all its method and custom guards pass.
  7. The first matching route calls its handler.

Middleware can return a response without calling the service it wraps. When that happens, ntex skips every later stage.

Where routing stops determines which fallback runs:

  • If no resource path-and-guard combination matches, the application default service runs. The built-in application default returns 404 Not Found.
  • If a resource path matches but none of its routes match, the resource default service runs. The built-in resource default returns 405 Method Not Allowed.
  • Resource fallbacks are independent of application and scope fallbacks.
use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new()
    .service(
        web::resource("/reports")
            .route(web::get().to(async || HttpResponse::Ok()))
            .default_service(
                web::to(async || HttpResponse::MethodNotAllowed()),
            ),
    )
    .default_service(
        web::to(async || HttpResponse::NotFound().body("Unknown path")),
    );

Resource guards help the application router choose a resource. Route guards are checked later, after a resource has been selected. Guards can check methods, headers, or custom conditions:

use ntex::http::Method;
use ntex::web::{self, guard, HttpResponse};

let app = web::App::<()>::new().service(
    web::resource("/events").route(
        web::route()
            .method(Method::POST)
            .guard(guard::Header("content-type", "application/json"))
            .to(async || HttpResponse::Accepted()),
    ),
);

Guards only receive the request head. They are a good fit for methods, headers, and other request metadata, but they cannot inspect the request body or values produced by extractors. Use a filter, extractor, or handler for those checks.

This matters when a request uses the wrong method. App::route("/reports", web::get().to(handler)) uses the application fallback for a non-GET request because the method guard belongs to the generated resource. In contrast, web::resource("/reports").route(web::get().to(handler)) first selects the resource by path, then uses its default 405 Method Not Allowed response when the method guard fails. Scope::route() behaves like App::route().

Order Matters

Resources and scopes are considered in registration order, and the first path whose guards pass wins. Routes inside a resource follow the same rule. Put specific patterns before broad dynamic or remainder patterns:

use ntex::web::{self, App};

let app = App::default()
    .service(web::resource("/files/status").to(async || "ready"))
    .service(web::resource("/files/{path}*").to(async || "file"));

Reversing these registrations would let the remainder resource handle /files/status. For the same reason, add an unguarded route with Resource::to() after guarded routes; once it matches, later routes cannot be reached.

Use App::middleware() to wrap the whole application. Use App::filter() to transform every incoming WebRequest before routing. Resources and scopes offer the same methods when the behavior should apply to only part of the application. See Web Application Filters and Middleware for more information.

Route Path Format

Route patterns contain static and dynamic segments separated by /. ntex adds a leading / to resource and scope paths when needed, but including it makes the full route easier to read.

Static Paths

A static pattern matches the same path:

/users
/users/profile

Trailing slashes matter: /users and /users/ are different paths. Query strings are not included in path matching.

Routing is case-sensitive by default. App::case_insensitive_routing() makes static path segments ASCII case-insensitive, but does not change how dynamic segments are matched. Scopes can enable the same behavior for their own nested routes.

Dynamic Segments

Write a dynamic segment as {name}. It matches one non-empty path segment:

/users/{id}
/teams/{team}/users/{user}

You can combine variables with static text or use several variables in one segment:

/releases/v{major}.{minor}
/files/{name}.{extension}

Add a regular expression as {name:regex} to restrict what a variable accepts:

/users/{id:[0-9]+}
/releases/{version:v[0-9]+\.[0-9]+}

The regular expression must match the whole segment. Invalid route patterns and regular expressions panic while the application is built, so define routes as trusted application configuration rather than from user input.

Remainder Matches

Add * after a dynamic variable to capture the rest of the path, including / separators:

/files/{path}*

This matches both /files/readme.txt and /files/images/logo.svg. The path value is readme.txt in the first case and images/logo.svg in the second. The default expression is .*, so the captured value may be empty. Remainder matches cannot use a custom regular expression.

Unlike normal dynamic segments, a remainder value is not percent-decoded. A request for /files/my%20file.txt captures my%20file.txt. Decode the value yourself if the handler needs the decoded path, and validate it before using it as a file system path.

Static tail patterns such as /files/* also work, but a named remainder is usually clearer and works naturally with typed path extraction.

Multiple Patterns

A resource or scope can use an array or vector to accept several patterns:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new()
    .service(
        web::resource(["/health", "/status"])
            .to(async || HttpResponse::Ok()),
    );

Every pattern points to the same service.

When a multi-pattern resource is named, URL generation uses its first pattern. Put the canonical path first and treat the remaining patterns as aliases.

Accessing Path Variables

ntex percent-decodes variables matched by normal dynamic segments and stores them in the request’s match information. Remainder values are stored as they appear in the request path. The easiest way to read them is with the typed Path extractor:

use ntex::web::{self, HttpResponse};

async fn user(path: web::types::Path<(u32,)>) -> HttpResponse {
    let user_id = path.0;
    HttpResponse::Ok().body(format!("user {user_id}"))
}

let app = web::App::<()>::new().route(
    "/users/{id:[0-9]+}",
    web::get().to(user),
);

Tuples deserialize variables in path order. To access variables by name, use a struct that derives serde::Deserialize:

use ntex::web::{self, HttpResponse};

#[derive(serde::Deserialize)]
struct UserPath {
    organization: String,
    user_id: u32,
}

async fn user(path: web::types::Path<UserPath>) -> HttpResponse {
    HttpResponse::Ok().body(format!(
        "organization: {}, user: {}",
        path.organization,
        path.user_id,
    ))
}

let app = web::App::<()>::new().route(
    "/organizations/{organization}/users/{user_id:[0-9]+}",
    web::get().to(user),
);

The struct fields must have the same names as the route variables. This example requires the derive feature from serde.

You can also read variables directly from HttpRequest::match_info():

use ntex::web::{self, HttpRequest};

async fn file(req: HttpRequest) -> String {
    req.match_info()
        .get("path")
        .unwrap_or_default()
        .to_owned()
}

let app = web::App::<()>::new().route(
    "/files/{path}*",
    web::get().to(file),
);

ntex splits path segments before percent-decoding them. As a result, an encoded slash such as %2F can appear inside a normal dynamic variable after decoding. Escapes that are not valid UTF-8 remain percent-encoded.

Path values are still untrusted input. In particular, a variable that looked like one URL segment can decode to ../ or contain /. Validate it before using it as a file name, database key, or other identifier with stricter rules.

Scopes

A Scope groups related services under a shared path prefix:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new().service(
    web::scope("/api")
        .service(
            web::resource("/users")
                .route(web::get().to(async || HttpResponse::Ok())),
        )
        .service(
            web::resource("/users/{id}")
                .route(web::get().to(async || HttpResponse::Ok())),
        ),
);

These resources match /api/users and /api/users/{id}. You can nest scopes and use variables in their prefixes. Nested handlers can access those variables:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new().service(
    web::scope("/organizations/{org}")
        .route(
            "/users/{user}",
            web::get().to(
                async |path: web::types::Path<(String, String)>| {
                    HttpResponse::Ok()
                        .body(format!("{}:{}", path.0, path.1))
                },
            ),
        ),
);

A scope prefix matches complete path segments rather than arbitrary text. scope("/api") can contain routes below /api/, but it does not by itself provide a handler for the bare /api path and it never matches /apix. Inside the scope, an empty resource pattern ("") matches /api, while "/" matches /api/:

use ntex::web::{self, HttpResponse};

let app = web::App::<()>::new().service(
    web::scope("/api")
        .route("", web::get().to(async || HttpResponse::Ok().body("/api")))
        .route("/", web::get().to(async || HttpResponse::Ok().body("/api/"))),
);

A scope can have its own guards, middleware, filters, case-sensitivity setting, and fallback service. Once a scope matches, unmatched paths inside it use the scope fallback. Without a custom scope fallback, ntex returns its built-in 404 Not Found; it does not use the application fallback.

Named and External Resources

Give a resource a name when handlers need to link to it without hard-coding its path:

use ntex::web::{self, HttpRequest, HttpResponse, error::UrlGenerationError};

async fn index(req: HttpRequest) -> Result<HttpResponse, UrlGenerationError> {
    let url = req.url_for("user", ["42"])?;
    Ok(HttpResponse::Ok().body(url.to_string()))
}

let app = web::App::<()>::new()
    .service(
        web::resource("/users/{id}")
            .name("user")
            .route(web::get().to(async || HttpResponse::Ok())),
    )
    .route("/", web::get().to(index));

Here, the index handler generates an absolute URL such as http://some-host-name/users/42.

HttpRequest::url_for() returns a ntex::url::Url. Values fill dynamic segments in pattern order. For a resource inside a scope, values for dynamic scope segments come before values for the resource itself. Use HttpRequest::url_for_static() when the pattern has no dynamic segments. Each value is percent-encoded as one literal segment, so URL delimiters in a value cannot change the generated URL’s structure.

Keep resource names unique across the application. Names are application-wide lookup keys, not names local to a scope, and duplicate names make generated links depend on which definition is found.

Generation can fail if the name is unknown, too few values are supplied, or the completed URL is invalid. Returning UrlGenerationError from a handler, as above, avoids panicking on those errors.

For an application resource, the scheme and host come from the request’s ConnectionInfo, while the path comes from the resource and its containing scopes. ConnectionInfo can use Forwarded, X-Forwarded-Proto, X-Forwarded-Host, or Host, so a front-end proxy should replace untrusted forwarding headers before generated URLs are exposed to clients.

Use App::external_resource() to register a named URL that is not handled by the application. Register it before the first route or service, or register it inside App::configure() with ServiceConfig::external_resource():

use ntex::web::{self, HttpRequest, HttpResponse, error::UrlGenerationError};

async fn docs(req: HttpRequest) -> Result<HttpResponse, UrlGenerationError> {
    let url = req.url_for("documentation", ["page1.html"])?;
    Ok(HttpResponse::Ok().body(url.to_string()))
}

let app = web::App::<()>::new()
    .external_resource(
        "documentation",
        "https://docs.example.com/{page}",
    )
    .route("/docs", web::get().to(docs));

The docs handler generates https://docs.example.com/page1.html. Because the external pattern already contains a scheme and host, it does not use the request’s connection information. External resources are URL templates only; they do not participate in request routing.

Modular Configuration

As an application grows, keeping every route in one builder chain becomes hard to read. App::configure() provides a ServiceConfig that lets another function register part of the application:

use ntex::web::{self, HttpResponse};

fn configure_api(cfg: &mut web::ServiceConfig<()>) {
    cfg.route(
        "/health",
        web::get().to(async || HttpResponse::Ok()),
    );
    cfg.service(
        web::resource("/version")
            .to(async || HttpResponse::Ok().body("4")),
    );
}

let app = web::App::<()>::new()
    .configure(configure_api)
    .route("/", web::get().to(async || HttpResponse::Ok()));

ServiceConfig can register routes, services, and external resources. It does not create another routing boundary. The registered items join the application at the point where configure() is called.

Both applications and scopes support modular configuration.

Testing Routes

The web::test module lets you initialize an App, send requests to it, and inspect responses without opening a network socket:

use ntex::http::StatusCode;
use ntex::web::{self, HttpResponse};
use ntex::web::test::{TestRequest, call_service, init_service};

#[ntex::test]
async fn user_route() {
    let service = init_service(
        web::App::new().route(
            "/users/{id}",
            web::get().to(async || HttpResponse::Ok()),
        ),
    )
    .await;

    let request = TestRequest::get()
        .uri("/users/42")
        .to_request();
    let response = call_service(&service, request).await;

    assert_eq!(response.status(), StatusCode::OK);
}

It is worth testing method mismatches, trailing slashes, dynamic-variable constraints, scope boundaries, and fallback services. Each one exercises a different part of route selection.

Lower-Level HTTP Service

For most applications, web::server(factory) is the simplest way to start a server. It is equivalent to web::HttpServer::new(factory).

After you register routes and services, the application builder can become a service factory that converts http::Request values into http::Response values. HttpService uses this factory to handle HTTP/1.1 and HTTP/2 connections.

Use the lower-level server builder when you need to add connection-level services before HttpService. These services can inspect or transform the Io object and map the state passed to the HTTP and application services.

The same server can be built at the lower level like this:

use ntex::{http, server, web, SharedCfg};
use ntex::web::HttpResponse;

#[ntex::main]
async fn main() -> std::io::Result<()> {
    server::build()
        .bind("http", "127.0.0.1:8080", SharedCfg::new("S"), async |_| {
            http::HttpService::new(
                web::App::new()
                    .route("/", web::get().to(async || {
                        HttpResponse::Ok().body("Hello")
                    }))
                    .build(),
            )
        })?
        .run()
        .await
}

To give the web application its own state instead of the surrounding service pipeline’s state, see Web Application State.