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

```ts tab
// 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.

```ts tab
// 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);
```

```ts tab
// 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.ts` mounts `usersRouter`, and `users/index.ts` mounts 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 with `express.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`:
```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
```markdown
## 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 shared `app` object) 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 own `use` calls.
- **Router modules mounted at several paths:** the same router can appear on two `use` lines. 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 as `require`. 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?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-37: Why does my agent miss dynamically imported components?](https://beta.llmgraph.in/agent-readable/ar-37.md)
- [AR-20: Where should a registry live so coding agents can find it?](https://beta.llmgraph.in/agent-readable/ar-20.md)

Last verified 2026-10-30
