docs-site/content/docs/how-to/add-controller.md
+++ title = "Add a controller" description = "Generate a controller, wire up its routes and handlers, and mount it under a prefix or nested path." date = 2026-07-03T00:00:00+00:00 updated = 2026-07-03T00:00:00+00:00 draft = false weight = 10 sort_by = "weight" template = "docs/page.html" aliases = ["/docs/the-app/controller/"]
[extra] lead = "" toc = true top = false +++
Goal: add a new HTTP endpoint group to your Loco app — generated, or written by hand — and get it showing up in cargo loco routes.
This guide assumes a working Loco app (cargo loco start runs). For the full Routes/AppRoutes API and the exhaustive Hooks surface, see the Hooks reference.
cargo loco generate controller <NAME> [ACTION ...]
A generated controller is always a JSON API controller — there's no kind flag to pick. Additional positional arguments become extra actions (handler functions + routes) alongside the default index.
cargo loco generate controller notes list get
This:
src/controllers/notes.rs with an index handler plus one handler per extra action (list, get), each returning format::empty() as a starting pointpub mod notes; to src/controllers/mod.rs.add_route(controllers::notes::routes()) into your routes() implementation in src/app.rs — no manual wiring neededtests/requests/The generated routes() function looks like this:
// src/controllers/notes.rs
pub fn routes() -> Routes {
Routes::new()
.prefix("api/notes/")
.add("/", get(index))
.add("list", get(list))
.add("get", get(get))
}
Edit the handler bodies and route methods (get/post/put/delete, etc.) to fit your endpoint. Controllers return JSON by default; if you'd rather render server-side HTML, see Render server-side views.
cargo loco routes
[GET] /_ping
[GET] /_health
[GET] /_readiness
[GET] /api/notes/
[GET] /api/notes/list
[GET] /api/notes/get
If your new routes don't appear, check that src/app.rs's routes() implementation calls .add_route(controllers::notes::routes()) (the generator does this for you, but double-check after a manual edit or merge conflict).
Sometimes you want a controller without a generator scaffold — e.g. a small internal endpoint.
Create src/controllers/example.rs:
use loco_rs::prelude::*;
async fn hello() -> Result<Response> {
format::text("hello")
}
async fn echo(Json(body): Json<serde_json::Value>) -> Result<Response> {
format::json(body)
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(hello))
.add("/echo", post(echo))
}
Declare the module in src/controllers/mod.rs:
pub mod example;
Register its routes in src/app.rs's Hooks::routes:
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(controllers::example::routes())
}
AppRoutes::with_default_routes() also mounts the built-in /_ping, /_health, /_readiness monitoring endpoints.
Routes::prefix scopes every route added to that Routes instance:
pub fn routes() -> Routes {
Routes::new()
.prefix("notes")
.add("/", get(list))
.add("/{id}", get(get_one))
}
AppRoutes::prefix applies to every controller added after it:
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.prefix("/api")
.add_route(controllers::notes::routes())
.add_route(controllers::users::routes())
}
Use nest_prefix to append another path segment to the current prefix for routes added afterward, or nest_route/nest_routes to scope a prefix to just the routes passed in (without touching the running prefix):
fn routes(_ctx: &AppContext) -> AppRoutes {
let v1_notes = Routes::new().add("/", get(|| async { "notes v1" }));
AppRoutes::with_default_routes()
.prefix("api")
.add_route(controllers::auth::routes())
// only these routes get the extra `v1` segment: /api/v1/...
.nest_route("v1", v1_notes)
}
Routes::nest (on a Routes value, not AppRoutes) does the same job when you're composing route groups before returning them from a controller's routes() function — handy for merging several sub-resources with Routes::merge/merge_all and then nesting the result once:
let user_routes = Routes::new()
.add("/users", get(list_users))
.add("/users", post(create_user));
let product_routes = Routes::new().add("/products", get(list_products));
let api_routes = Routes::new().merge(user_routes).merge(product_routes);
Routes::new()
.add("/health", get(|| async { "ok" }))
.nest("/api", api_routes);
// -> GET /health, GET /api/users, POST /api/users, GET /api/products
tower::Layer to just one controller or routeRoutes::layer attaches a tower::Layer (rate limiting, custom auth, tracing, etc.) to every handler in that Routes value only — for middleware that should run on every route, see Add middleware instead.
// src/controllers/notes.rs
pub fn routes() -> Routes {
Routes::new()
.prefix("notes")
.add("/", get(list).layer(my_tower_layer()))
}
cargo loco routes
cargo test --test requests_notes # if the generator produced tests/requests/notes.rs
A curl against the new path should return your handler's response:
curl -s localhost:5150/api/notes/