Add a controller
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.
1. Generate a controller
cargo loco generate controller <NAME> [ACTION ...] (--api|--html|--htmx)One of --api, --html, or --htmx is required — there is no default kind; omitting all three is a hard error. Additional positional arguments become extra actions (handler functions + routes) alongside the default index.
cargo loco generate controller notes list get --apiThis:
- creates
src/controllers/notes.rswith anindexhandler plus one handler per extra action (list,get), each returningformat::empty()as a starting point - adds
pub mod notes;tosrc/controllers/mod.rs - injects
.add_route(controllers::notes::routes())into yourroutes()implementation insrc/app.rs— no manual wiring needed - generates a matching test file under
tests/requests/
The generated routes() function looks like this:
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. --html/--htmx generate the same shape plus src/views/<name>.rs view stubs and templates under assets/views/ — see Render server-side views for what to do with those.
2. Confirm the routes are registered
cargo loco routes[GET] /_ping[GET] /_health[GET] /_readiness[GET] /api/notes/[GET] /api/notes/list[GET] /api/notes/getIf 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).
3. Write a controller by hand (no generator)
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’sHooks::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.
4. Prefix a whole controller
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))}5. Prefix a whole app (or a group of controllers)
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())}6. Nest routes under an additional path segment
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/products7. Apply a tower::Layer to just one controller or route
Routes::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.
pub fn routes() -> Routes { Routes::new() .prefix("notes") .add("/", get(list).layer(my_tower_layer()))}Verify
cargo loco routescargo test --test requests_notes # if the generator produced tests/requests/notes.rsA curl against the new path should return your handler’s response:
curl -s localhost:5150/api/notes/