Route Groups
As you add endpoints, you’ll find repeated patterns: the same kind of endpoints start with /api/todos, may all require authentication later, and should appear together in the documentation. The new concept in this section is a route group, which lets you define these shared features in one place.
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;
var api = app.MapGroup("/api");
var todosApi = api.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", () => todos);
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
var todo = todos.Find(t => t.Id == id);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, Done: false);
todos.Add(todo);
return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});
todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());
api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);This chapter uses the CRUD example from Chapter 08 to demonstrate route groups. For now, it leaves out validation from Chapter 06 and shared error handling from Chapter 09, so the change stays focused on where endpoints are registered. Those features work with MapGroup too; without them, the 404 response for a missing resource in this chapter still has no body.
Run and verify
dotnet runAll Todo endpoints are now under /api/todos:
curl http://localhost:5080/api/todos[{"id":1,"title":"Buy milk","done":false}]curl http://localhost:5080/api/todos/1{"id":1,"title":"Buy milk","done":false}curl -i -X POST http://localhost:5080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write report"}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/todos/2
{"id":2,"title":"Write report","done":false}The health-check endpoint is at /api/health:
curl http://localhost:5080/api/health{"status":"ok"}The old /todos address no longer exists and returns 404.
Create a group with MapGroup
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;
var api = app.MapGroup("/api");
var todosApi = api.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", () => todos);
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
var todo = todos.Find(t => t.Id == id);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, Done: false);
todos.Add(todo);
return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});
todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());
api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);Line 19, app.MapGroup("/api"), creates a group. The api value represents “all routes beginning with /api.”
Line 21 calls MapGroup("/todos") on api to create a nested group with the full prefix /api/todos.
Then MapGet, MapPost, and other methods on todosApi use relative paths:
| Registration | Actual route |
|---|---|
todosApi.MapGet("/", ...) | GET /api/todos |
todosApi.MapGet("/{id:int}", ...) | GET /api/todos/{id} |
todosApi.MapPost("/", ...) | POST /api/todos |
todosApi.MapDelete("/{id:int}", ...) | DELETE /api/todos/{id} |
api.MapGet("/health", ...) | GET /api/health |
The group object works almost exactly like app; you can call MapGet, MapPost, and MapGroup on it. That’s what makes this useful: you don’t need to learn a new API; you just call it on a different object.
Why not write the full path for each route? The prefix is a decision, so it should appear only once. If one day the API needs to move to /api/v2, a route group means you change only line 19. If the prefix is repeated on each endpoint, you have to update each one and may miss one, leaving inconsistent URLs.
WARNING
Line 35’s Created address, $"/api/todos/{todo.Id}", is still a manually written full path. The group does not add its prefix there automatically. Remember to update it too if you change the group prefix.
Add metadata to a group
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;
var api = app.MapGroup("/api");
var todosApi = api.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", () => todos);
todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
var todo = todos.Find(t => t.Id == id);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});
todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, Done: false);
todos.Add(todo);
return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});
todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());
api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);A group is more than a route prefix. Line 21’s .WithTags("Todos") adds an OpenAPI tag to the group, and all endpoints in the group inherit it. Line 41 gives the health-check endpoint its own “System” tag.
Open /scalar. The endpoint list on the left is split into two groups by tag: four endpoints under “Todos” and one under “System.” In /openapi/v1.json, the four Todo endpoints each have tags: ["Todos"], while /api/health has tags: ["System"].
Technical detail
Earlier chapters didn’t set tags, so Scalar used the project name (such as FirstSteps) as the default tag for all endpoints.
Calls such as WithTags add metadata to an endpoint. They don’t change the handler’s logic; they attach a “tag” for other parts of the framework to read. Metadata added to a group applies to every endpoint in it, making groups a convenient place for shared configuration. Later chapters add other features to groups, such as:
- Authorization:
todosApi.RequireAuthorization()requires authentication for every endpoint in the group. - CORS: enable cross-origin access for a group of endpoints.
Configure once and it applies to the whole group, including endpoints added later, so you don’t create a security hole by forgetting to configure one.
FastAPI comparison
MapGroup is similar to FastAPI’s APIRouter(prefix="/todos", tags=["Todos"]). FastAPI requires mounting a router later with app.include_router(). An ASP.NET Core group is already attached to the app when created with app.MapGroup(), and can be nested as on line 21.
Summary
app.MapGroup("/prefix")creates a route group. Endpoints registered on it use relative paths, and the prefix is added automatically.- Groups can be nested:
api.MapGroup("/todos")has the prefix/api/todos. - Metadata added to a group (such as
WithTags) applies to all endpoints in it. You can also configure features such as authorization and CORS for a group. - Shared prefixes and configuration are declared once and are easy to update. Manually written full paths, such as the
Createdaddress, still need to be updated separately.
This is the final chapter in the “Requests and Responses” stage. You can now write an API with complete parameters, validation, consistent responses, and a clear structure. The next stage, “Application Structure,” starts with Dependency Injection and replaces the in-memory list in our examples with real services. Previous: Status Codes and Error Handling.
