Agent Rules / Frameworks
How should Express routes be organised for coding agents?
Rule AR-38
Register Express routes and middleware explicitly in one router module. Don't require route files by computed path.
Express gives you express.Router for splitting routes across files. Mount each router with a literal import and an app.use line, and the app's URL map is readable from one file. Loading every file in a routes/ directory by name hides which files run, in what order, and under which path.
Why it happens
The Express routing guide recommends express.Router for modular, mountable route handlers, and notes that a router instance works as a self-contained middleware and routing system, often called a mini-app. Its example exports a router from birds.mjs, imports it, and mounts it with app.use('/birds', birds). That form puts a literal import and a literal path in the app file.
Order is part of the program. The middleware guide describes an app as "essentially a series of middleware function calls executed during the request-response cycle." If a middleware doesn't end the cycle it must call next(), and in the guide's route example the second route "will never get called because the first route ends the request-response cycle." Error-handling middleware is identified by having four arguments, and the guide's examples place it after the routes. The error handling guide states it directly: "You define error-handling middleware last, after other app.use() and routes calls".
A loop that loads route files breaks both properties. Node's module documentation defines require(X) by the string X, resolved through core modules, relative paths and node_modules, and says nothing about computed arguments. So when the argument is built at runtime (require(path.join(dir, file))), the set of loaded files depends on what the directory contains when the process starts, and the mount order depends on the order the file system lists them. Neither appears in the source. A search for users.routes finds the file but no line that mounts it.
Bad and good
An API with three route files.
// bad: app.ts loads whatever is in routes/
import fs from "node:fs";
import path from "node:path";
import express from "express";
const app = express();
const dir = path.join(__dirname, "routes");
for (const file of fs.readdirSync(dir)) {
const router = require(path.join(dir, file));
app.use("/" + file.replace(".js", ""), router);
}Adding routes/admin.js publishes /admin with no edit to the app. Renaming users.js changes a URL. An agent asked "which file handles /users/:id?" has to infer it from the loop.
// good: routes/index.ts is the one place that lists every router
import { Router } from "express";
import { usersRouter } from "./users";
import { ordersRouter } from "./orders";
import { adminRouter } from "./admin";
import { requireAuth } from "../middleware/auth";
export const routes = Router();
routes.use("/users", requireAuth, usersRouter);
routes.use("/orders", requireAuth, ordersRouter);
routes.use("/admin", requireAuth, adminRouter);// app.ts
import express from "express";
import { routes } from "./routes";
import { errorHandler } from "./middleware/errors";
const app = express();
app.use(express.json());
app.use("/api", routes);
app.use(errorHandler);Every path, every router and the auth middleware in front of it are written in routes/index.ts. The order is the line order. Each router file exports a named value that a search can follow to this one mount point.
Trade-offs and when dynamic loading is acceptable
- One more line per router. Adding a route file means editing the index. That edit is the record that the route is public.
- Large apps can nest.
routes/index.tsmountsusersRouter, andusers/index.tsmounts its own sub-routers. Each level stays a literal list. - **
app.route()** lets you chain methods for one path in one place, which the guide says "reduces redundancy and typos". It is a good fit inside a router file. - Plugin systems (a server that loads third-party route modules) are open sets by design. Keep the loader in one module and make the contract a typed interface (AR-09).
- **
mergeParams:** if a parent route has path parameters, the guide says they are not available to sub-routes unless the router is created withexpress.Router({ mergeParams: true }). Explicit mounts make that dependency visible.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| typescript-eslint | no-require-imports (in recommended; options allow, allowAsImport) | require() calls and import x = require() in TypeScript, which pushes route loading to literal import | 2026-10-30 |
| eslint | no-restricted-syntax with a CallExpression selector such as CallExpression[callee.name='require'][arguments.0.type!='Literal'] | require(expression) calls. Selector syntax is documented on the rule page. Checked with eslint 9.39.5: this selector flags the computed require(...) calls in a CommonJS file. | 2026-10-30 |
| eslint | global-require | deprecated in ESLint v7.0.0 and scheduled for removal in v11.0.0; the page points to a replacement in eslint-plugin-n. Not recommended for new config. | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended) | any typing on a loaded router | 2026-10-30 |
| tsconfig | strict | implicit any on a value returned by require | 2026-10-30 |
In eslint.config.js:
{
rules: {
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='require'][arguments.0.type!='Literal']",
message: "Mount routers with a literal import in routes/index.",
},
],
},
}Checked with eslint 9.39.5 on a CommonJS file with require(path.join(dir, file)): the selector flags it.
Rule block for AGENTS.md / CLAUDE.md
## Express routes
- Each route file exports one named `Router`. All routers are mounted in `routes/index.ts` with a literal import and `routes.use("/path", ...)`.
- Don't load route files with `fs.readdirSync` plus `require`, or with any `require`/`import()` of a computed path.
- Middleware that applies to a router is written on its mount line, in the order it should run.
- When you add, rename or remove a route file, edit `routes/index.ts` in the same change and list the URL prefix that changed.Edge cases
- Route files that register themselves (
app.get(...)at import time through a sharedappobject) work, but order then depends on import order. Prefer exporting a router. - **
next('router'):** the middleware guide shows leaving a router early with it, so control goes back to the parent. A reader following the mount list should check the router's ownusecalls. - Router modules mounted at several paths: the same router can appear on two
uselines. Searching for the router name finds both. - ES modules: the routing guide's own snippet imports
'./birds'without an extension, and Node's native ESM loader needs the full file path. Use the extension your setup requires. - **Dynamic
import()of route files** has the same visibility cost asrequire. See AR-37.
References
- Express, routing guide: https://expressjs.com/en/guide/routing.html
- Express, using middleware: https://expressjs.com/en/guide/using-middleware.html
- Node.js, modules (
require): https://nodejs.org/api/modules.html - typescript-eslint
no-require-imports: https://typescript-eslint.io/rules/no-require-imports/ - ESLint
no-restricted-syntax: https://eslint.org/docs/latest/rules/no-restricted-syntax - ESLint
global-require: https://eslint.org/docs/latest/rules/global-require - TSConfig reference: https://www.typescriptlang.org/tsconfig/
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved (rules-of-hooks, Express error handling, Node events, pyright configuration).
- 2026-10-30: tool checks resolved (eslint 9.39.5, tsc 7.0.2, pyright 1.1.414).
Related
- AR-09 How should plugin registries be written for coding agents?
- AR-37 Why does my agent miss dynamically imported components?
- AR-20 Where should a registry live so coding agents can find it?
Last verified