Skip to content

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.

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.

A single route is one method call:

src/app/products/routes.ts
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.

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 methods

Most apps need the top five. head / options / any are there when you reach for them.

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");
// or
const 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.

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.

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.

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:

OptionWhat it does
prefixPrepended to every route inside
middlewareApplied to every route inside, in array order
nameRoute-name prefix (used for URL generation)

Middleware runs in array order before the controller. If you stack [rateLimitMiddleware, authMiddleware], rate-limiting runs first.

Most projects with @warlock.js/auth define a tiny helper in src/app/shared/utils/router.ts:

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.

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/:id

Each 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:

SlotMethodPathTypical use
.list(h)GET/productspaginated list
.show(h)GET/products/:idget one
.create(h)POST/productscreate new
.update(h)PUT/products/:idreplace one
.destroy(h)DELETE/products/:iddelete 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.

Here’s the actual faqs module from the reference codebase — every common pattern in eighteen lines:

src/app/faqs/routes.ts
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.

For auth flows where most routes are public but a couple require a token:

src/app/auth/routes.ts
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.

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. Anyone
who can reach the server can call them. Add auth middleware (e.g. register them
inside 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.

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.

  • Never import routes.ts from anywhere. The framework auto-loads it. Importing it double-registers every route.
  • Order matters for static-vs-param overlap. Register router.get("/products/featured", ...) before router.get("/products/:id", ...) if you want featured to 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 group runs in array order, then the controller. [rateLimitMiddleware, authMiddleware] means rate-limit first, auth second.
  • 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 guarded with role-based middleware.

Continue to Controllers to see how a handler reads inputs and shapes responses.

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.

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.

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.

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).