Routing
The router is how URLs become handlers. In Warlock it’s a singleton — you import router from @warlock.js/core and call .get(...) / .post(...) / a group / a resource chain. Every call registers a route synchronously; the framework collects them at boot and binds them to Fastify.
The point of this page is the shape — what’s available, where it lives, and the patterns you’ll see in every Warlock module. Deep details (rate limiting, route names, source-file tracking for HMR) live in Routing — deep dive.
Where routes live
Section titled “Where routes live”Every module owns a routes.ts at the root of its folder:
src/app/ faqs/ routes.ts ← public surface of this module controllers/ services/ ...routes.ts is auto-loaded by the framework at boot. Don’t import it from anywhere. Importing causes a double-registration error.
If a module has no public HTTP surface (e.g. a worker-only module), skip the file entirely — routes.ts is optional.
The basics
Section titled “The basics”A single route is one method call:
import { router } from "@warlock.js/core";import { listProductsController } from "./controllers/list-products.controller";
router.get("/products", listProductsController);That’s the entire contract for a simple route. router is the singleton; listProductsController is a plain function typed as RequestHandler.
HTTP verbs
Section titled “HTTP verbs”The router covers the five everyday verbs plus the niche ones:
router.get(path, handler);router.post(path, handler);router.put(path, handler);router.patch(path, handler);router.delete(path, handler);router.head(path, handler);router.options(path, handler);router.any(path, handler); // matches all methodsMost apps need the top five. head / options / any are there when you reach for them.
Path parameters
Section titled “Path parameters”URL parameters use :name and land on request.params:
router.get("/products/:id", getProductController);router.get("/posts/:postId/comments/:commentId", showCommentController);Inside the handler:
const id = request.input("id");// orconst id = request.params.id;// or, the typed shortcut for the canonical `/:id`:const id = request.idParam;request.input(...) reads from query, params, and body uniformly — convenient when you don’t care which segment a value came from.
Multiple paths, one handler
Section titled “Multiple paths, one handler”Pass an array of paths to register the same handler against each:
router.get(["/health", "/healthz"], healthController);Useful for legacy alias paths or platform-specific probe URLs.
Prefix groups
Section titled “Prefix groups”A whole module typically prefixes its URLs with /<module-name>. Use router.prefix(prefix, callback):
router.prefix("/products", () => { router.get("/", listProductsController); // GET /products router.get("/:id", getProductController); // GET /products/:id router.post("/", createProductController); // POST /products router.patch("/:id", updateProductController); // PATCH /products/:id router.delete("/:id", deleteProductController); // DELETE /products/:id});Nested prefix calls compose. This:
router.prefix("/api", () => { router.prefix("/v1", () => { router.get("/health", healthController); });});…registers GET /api/v1/health. The router collects prefixes onto a stack and joins them.
Middleware groups
Section titled “Middleware groups”router.group(options, callback) is prefix plus middleware (and an optional route-name prefix):
import { authMiddleware } from "@warlock.js/auth";
router.group( { prefix: "/admin", middleware: [authMiddleware("admin")], }, () => { router.get("/dashboard", dashboardController); router.delete("/users/:id", removeUserController); },);Three options:
| Option | What it does |
|---|---|
prefix | Prepended to every route inside |
middleware | Applied to every route inside, in array order |
name | Route-name prefix (used for URL generation) |
Middleware runs in array order before the controller. If you stack [rateLimitMiddleware, authMiddleware], rate-limiting runs first.
The guarded() convention
Section titled “The guarded() convention”Most projects with @warlock.js/auth define a tiny helper in src/app/shared/utils/router.ts:
import { authMiddleware } from "@warlock.js/auth";import { router } from "@warlock.js/core";
export function guarded(callback: () => void) { router.group( { middleware: [authMiddleware("user")], }, callback, );}Now every “requires-login” block reads naturally:
import { guarded } from "app/shared/utils/router";
router.prefix("/auth", () => { router.post("/login", loginController); router.post("/register", registerController); guarded(() => { router.post("/logout", logoutController); router.get("/me", meController); });});A single line — guarded(() => { ... }) — communicates “everything in this block needs a valid user token.” For an admin-only block, register an admin user type and use authMiddleware(["admin"]) directly rather than wrapping an empty-array authMiddleware([]) call in a helper named ...Admin — an empty allow-list accepts any authenticated user type, so a name like that would promise a boundary it doesn’t enforce. The pattern keeps routes.ts clean of middleware repetition.
RESTful resource chain
Section titled “RESTful resource chain”For a standard CRUD resource, router.route(path) returns a builder with five named slots:
router .route("/products") .list(listProductsController) // GET /products .show(getProductController) // GET /products/:id .create(createProductController) // POST /products .update(updateProductController) // PUT /products/:id .destroy(removeProductController); // DELETE /products/:idEach method returns the builder so you can chain. Methods you don’t call don’t register — list-only resources stop at .list(...).
The slots map to method + path like this:
| Slot | Method | Path | Typical use |
|---|---|---|---|
.list(h) | GET | /products | paginated list |
.show(h) | GET | /products/:id | get one |
.create(h) | POST | /products | create new |
.update(h) | PUT | /products/:id | replace one |
.destroy(h) | DELETE | /products/:id | delete one |
There’s also .patch(h), .patchOne(h), .getOne(h), and a .crud({ ... }) shorthand for passing every handler in one object — see Routing — deep dive.
A complete routes.ts
Section titled “A complete routes.ts”Here’s the actual faqs module from the reference codebase — every common pattern in eighteen lines:
import { router } from "@warlock.js/core";import { guarded } from "app/shared/utils/router";import { createFaqController } from "./controllers/create-faq.controller";import { deleteFaqController } from "./controllers/delete-faq.controller";import { getFaqController } from "./controllers/get-faq.controller";import { listFaqsController } from "./controllers/list-faqs.controller";import { updateFaqController } from "./controllers/update-faq.controller";
guarded(() => { router .route("/faqs") .list(listFaqsController) .show(getFaqController) .create(createFaqController) .update(updateFaqController) .destroy(deleteFaqController);});Read it left-to-right: every route in this module requires a logged-in user (guarded), the resource path is /faqs, and the five CRUD slots map to the matching controller. A new teammate can read this file in five seconds and know the entire public surface.
Public + guarded mix
Section titled “Public + guarded mix”For auth flows where most routes are public but a couple require a token:
router.prefix("/auth", () => { router.post("/login", loginController, { public: true }); router.post("/register", registerController, { public: true }); router.post("/forgot-password", forgotPasswordController, { public: true });
guarded(() => { router.post("/logout", logoutController); router.post("/logout-all", logoutAllController); router.get("/me", meController); });});guarded(...) blocks compose freely inside a prefix(...). Stack as many as the module needs.
Public mutating routes
Section titled “Public mutating routes”A POST, PUT, PATCH or DELETE route must say who may call it: run auth
middleware (directly, through its route builder, or from an enclosing group
such as guarded()), or pass public: true when it is meant to be open — a
login, a webhook, a contact form. public changes nothing at runtime; it
records the decision.
router.post("/auth/login", loginController, { public: true });
// Every route in the group is public; a route can opt back out with `public: false`.router.group({ prefix: "/webhooks", public: true }, () => { router.post("/stripe", stripeWebhookController);});warlock dev lists, once per route, every mutating route that has neither:
[router] 1 mutating route has no auth middleware and is not marked public. Anyonewho can reach the server can call them. Add auth middleware (e.g. register theminside a guarded group), or pass { public: true } if they are meant to be open: - POST /api/posts (src/app/posts/routes.ts)It is a development warning only: production never checks, and the route
works exactly as before. Page routes are skipped (pages guard themselves with
requireUser()). authMiddleware() counts as a guard (its { optional: true }
form does not); so does any middleware whose function name contains auth or
guard. Mark a differently-named guard with markAuthGuard():
import { markAuthGuard, type Middleware } from "@warlock.js/core";
export const requireApiKey: Middleware = markAuthGuard(async ({ request, response }) => { if (request.headers["x-api-key"] !== process.env.API_KEY) return response.unauthorized();});A guard declared once on a group counts for every route inside it, at any depth,
and warlock inspect, the generated AGENTS.md (“Mutating routes without an auth
guard”) and the MCP inspect_routes tool read the same chain, so a route inside
router.group({ prefix: "/api/admin", middleware: [adminGuard()] }, …) is not
listed as unguarded. A guard counts there when it carries the markAuthGuard()
marker or its function name contains auth or guard; an anonymous function
returned by a factory needs the marker.
Turn the warning off with http.warnUnguardedRoutes: false. The same check runs
over the source, before the app boots, as warlock check
code WL9006. It reads source: a guard counts when its name contains auth or guard, or when it is a
constant initialised with markAuthGuard() in the same file, or when a local factory
(const requireAdmin = () => markAuthGuard(fn)) is called in a middleware list. A differently-named guard imported from
another file needs a // warlock-check-ignore WL9006 comment on its route.
API versioning
Section titled “API versioning”router.version(n, callback) is shorthand for router.prefix("/v<n>", ...):
router.version(1, () => { router.get("/users", listUsersV1); // /v1/users});
router.version(2, () => { router.get("/users", listUsersV2); // /v2/users});Combine with prefix and group as needed.
Gotchas
Section titled “Gotchas”- Never import
routes.tsfrom anywhere. The framework auto-loads it. Importing it double-registers every route. - Order matters for static-vs-param overlap. Register
router.get("/products/featured", ...)beforerouter.get("/products/:id", ...)if you wantfeaturedto be a literal match, not:id = "featured". Easier rule: use distinct paths when you can. - Don’t put logic in
routes.ts. It’s a registration file. If you want conditional registration (env-flagged endpoints), do it inside the controller, not by branching at registration time — branching breaks the dev-server’s HMR diff. - Middleware in
groupruns in array order, then the controller.[rateLimitMiddleware, authMiddleware]means rate-limit first, auth second.
See also
Section titled “See also”- Controllers — what the handler on the other side of the route looks like.
- Routing — deep dive — route names, rate limits, source-file tracking for HMR, the
.crud(...)shorthand, nested resource builders. - Middleware — writing custom middleware, the request/response contract, common patterns.
- Protected routes recipe — combining
guardedwith role-based middleware.
Continue to Controllers to see how a handler reads inputs and shapes responses.
Named API route metadata
Section titled “Named API route metadata”Name an API route when a browser form needs to address it by a stable contract:
router.post("/orders/:id", createOrderController, { name: "orders.create" });router.getNamedApiRoutes() returns fresh, frozen snapshots of registered named
API routes. Each snapshot is { name, path, method }. It excludes page routes,
handlers, middleware, schemas, and source paths, so adapters can publish route
metadata to a browser without exposing server implementation details. A route
registered with router.any() retains method "all"; a browser caller must
choose an explicit request method rather than guessing one.
Keep a route out of the browser
Section titled “Keep a route out of the browser”A named API route is embedded in the browser route table of a page only when that
page’s client code can reach its name (see
Form submission).
Some endpoints must never be reachable from browser code, such as an internal
webhook or a job trigger. Opt them out with clientVisible: false:
router.post("/internal/reindex", reindexController, { name: "internal.reindex", clientVisible: false,});Such a route is never written into a page document, and warlock check reports a
browser reference to its name as WL9020. Its snapshot carries clientVisible: false
and nothing else extra. Server-side authorization still decides who may call the endpoint.
Offer a route to some sites only
Section titled “Offer a route to some sites only”In a multi-site app (web.sites), the router is shared: a route registered once
answers on every host. Every route is therefore shared by default, and a page of
any site may embed it in its browser route table. Declare the sites a route is
offered to with sites, on the route or on a group (a route’s own sites
replaces its group’s):
router.group({ prefix: "/api/admin", sites: ["admin"] }, () => { router.post("/users", createUserController, { name: "admin.users.create" });});A page of the shop site never embeds admin.users.create; a page of the admin
site can. This scopes the browser table only: the endpoint still answers on every
host, so keep its guard. The snapshot carries sites only on routes that declare it.
Guarded routes and the route table
Section titled “Guarded routes and the route table”The snapshot carries guarded: true on a route with an auth guard in its
middleware chain (group middleware included, see
public mutating routes). Web leaves such a route out of the table for a guest
viewer. The Web option web.namedApiRoutes adds private path prefixes, allowlists and filters
(see Form submission).