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:
- Application middleware wraps the application service.
- Application filters process the incoming
WebRequest. - The application router finds a
Resourcewhose path and resource guards match. - Resource middleware wraps the selected resource’s filter and routes.
- The resource filter processes the request.
- The resource checks its routes in registration order. A route matches only if all its method and custom guards pass.
- 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.