Routing
Route templates match a URL to one endpoint using literal segments, captured parameters, constraints, catch-alls and shared-prefix groups.
"/{literalSegment}/{parameter}/{parameter:constraint}/{**catchAll}"Routing is how the framework decides which endpoint answers a request. The pattern string you pass to MapGet is a route template. It is read as segments split on /.
The pieces
- A literal segment is plain text and must match exactly, ignoring case.
/statusalso matches/Status. - A route parameter is a name in braces.
{id}matches one segment and captures its text. A handler parameter with the same name receives it. - A constraint follows a colon.
{id:int}matches only when the text parses as anint. Others includeguid,bool,alpha,length(6),min(1),range(1,100)andregex(...). - A catch-all parameter takes the rest of the path, slashes included:
{*rest}or{**rest}. The difference appears only when generating a URL:*escapes/as%2F, while**leaves it intact. {id?}is optional;{page=1}supplies a default, so the parameter always has a value.
Which route wins
Routes are not tried in registration order. Within a segment, a literal beats a constrained parameter, which beats a plain parameter, which beats a catch-all. So /orders/{id:int} and /orders/{reference} can coexist.
Groups
app.MapGroup("/admin") returns a RouteGroupBuilder: every endpoint added to it gets that prefix. Groups nest, and their prefixes may contain parameters.
Example
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Literal segments only. Matching ignores case, so /Status matches too.
app.MapGet("/status", () => "ok");
// A route parameter with a constraint. The handler parameter named id
// receives the parsed value.
app.MapGet("/orders/{id:int}", (int id) => new { Id = id, Total = 42 });
// Same shape, no constraint. Tried only when the segment is not an
// integer, because the constrained route is more specific.
app.MapGet("/orders/{reference}", (string reference) => new { Reference = reference });
// A catch-all takes everything left, slashes and all.
app.MapGet("/files/{**path}", (string path) => $"serving {path}");
// A group gives every endpoint inside it a shared prefix.
var admin = app.MapGroup("/admin");
admin.MapGet("/ping", () => "admin ok");
app.Run();When to use it
- Serve /orders/{id} and /orders/{reference} from the same prefix without ambiguity.
- Return 404 rather than 400 when someone requests /orders/abc.
- Give every endpoint in an admin or v2 section a shared prefix in one line.
- Accept an arbitrary file path as the tail of a URL.
More examples
Constraints doing the filtering
The difference between a default and an optional parameter is the one people get wrong. A default always produces a value, so the handler can take a plain int. An optional parameter may be missing, so the handler parameter must be nullable or the binding fails. Constraints are matching rules, not validation: /codes/abc returns 404 because no route matched, not 400.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// {page=1} has a default, so /articles and /articles/3 both match.
app.MapGet("/articles/{page=1}", (int page) => $"page {page}");
// {format?} is optional, so it may be absent and the parameter is nullable.
app.MapGet("/reports/{name}/{format?}", (string name, string? format) =>
$"{name} as {format ?? "json"}");
// Two constraints on one parameter, applied left to right: exactly six
// characters, and all of them letters.
app.MapGet("/codes/{code:length(6):alpha}", (string code) => code.ToUpperInvariant());
app.Run();Nested groups with a parameter in the prefix
This is how a multi-tenant or versioned API avoids repeating itself. Every path under the group carries the tenant identifier, and each handler declares only the parameters it cares about. Later you will also attach authorisation and OpenAPI metadata to a group once, instead of to each endpoint.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// A group prefix may itself contain route parameters.
RouteGroupBuilder tenants = app.MapGroup("/tenants/{tenantId:int}");
// Endpoints inside see the outer parameter as well as their own.
// This answers /tenants/4/users/9.
tenants.MapGet("/users/{userId:int}", (int tenantId, int userId) =>
new { TenantId = tenantId, UserId = userId });
// Groups nest. This one answers /tenants/4/reports/daily.
RouteGroupBuilder reports = tenants.MapGroup("/reports");
reports.MapGet("/daily", (int tenantId) => new { TenantId = tenantId, Report = "daily" });
app.Run();Both catch-all forms side by side
On the way in both behave the same: /double/2026/q1/report.pdf gives rest the value 2026/q1/report.pdf. The two differ only when the framework builds a link back to the route, which happens with named endpoints and redirects. Use ** unless you specifically want the slash escaped.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Single star: captures the remainder, but escapes / as %2F when this
// route is used to GENERATE a URL.
app.MapGet("/single/{*rest}", (string rest) => $"single: {rest}");
// Double star: captures the remainder and round-trips / unescaped.
// Prefer this for anything file-path shaped.
app.MapGet("/double/{**rest}", (string rest) => $"double: {rest}");
app.Run();
Discussion