# Why did renaming an Express route file break my client?

> Chimera CH-06 (Express (Node, CommonJS)): Why did renaming an Express route file break my client?

`app.js` lists the `routes/` directory, requires each file by a path it builds at startup, and mounts the router under a prefix taken from the file name. The file name is now a public URL, and nothing in the source names that URL. Rename `invoice.js` to `invoices.js` and the API moves to a new path while the client keeps calling the old one. Write an explicit route table that imports each router by a literal path and mounts it with a literal prefix.

The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and server to confirm it.

## 1. The setup

Express makes this pattern short, and the parts are good ones. `express.Router` creates a mountable set of route handlers, `app.use` mounts it at a path, and a directory scan means a new route file needs no edit to the app. The billing API has three route files, each calling into a file in `services/`.

```javascript
// before/app.js
const routesDir = path.join(__dirname, "routes");
for (const file of fs.readdirSync(routesDir)) {                // line 10
  const name = path.basename(file, ".js");                     // line 11
  app.use("/" + name, require("./routes/" + file));            // line 12
}
```

```javascript
// before/routes/invoices.js
const { getInvoice } = require("../services/invoices");
...
module.exports = router;                                       // line 16
```

```javascript
// before/client/api.js
const getInvoice = (id) => fetchJson("/invoice/" + id);        // line 12
```

The tests call the services directly (`tests/services.test.js`) and request one route through a live server (`tests/app.test.js`, `GET /customers/1`).

## 2. Where the call path hides

- **The require argument is built at runtime.** Node documents `require(id)` as taking a module identifier, with a relative path resolved against the file that calls it. At `app.js:12` the identifier is `"./routes/" + file`, and `file` comes from the directory listing at line 10. No source line contains the text `./routes/invoices`.
- **The URL prefix is computed too.** `name` at `app.js:11` is the file name without `.js`. The routing guide mounts a router with a literal path, `app.use('/birds', birds)`. Here the path is `"/" + name`, so `/invoices` appears in no file.
- **The router has no importer.** `routes/invoices.js` ends with `module.exports = router` at line 16. A search for `require("./invoices")` or for the router's name finds nothing, because the only require that loads it is the loop.
- **The client holds the URL as a string.** `client/api.js:12` writes `"/invoice/"`. It is joined to the server only by an HTTP request.

## 3. The edit that breaks it, and which call is left wrong

The route files for customers and products are plural, so `routes/invoice.js` is renamed `routes/invoices.js`. The edit is finished in the files it touches: the service import inside the route file still works, and the service tests call `getInvoice` directly and pass.

```javascript
// before/routes/invoices.js (edit applied)
// This file was routes/invoice.js and is now routes/invoices.js
```

Nothing at startup complains. The loop at `app.js:10` finds `invoices.js`, `name` becomes `invoices`, and line 12 mounts the router at `/invoices`. The prefix `/invoice` no longer exists.

The call left wrong is `client/api.js:12`, `fetchJson("/invoice/" + id)`. That request reaches no route. The Express FAQ describes the result: a 404 is not an error, it means Express ran all middleware functions and routes and found that none responded. `fetchJson` throws at `api.js:6` with the status in the message. Customers (`api.js:11`) and products (`api.js:13`) are unchanged and still work, so a smoke test on either passes.

No test reaches that URL. `tests/app.test.js:25` is written as the test that would catch it, marked `todo` with the reason. With Express 5.3.0 on Node 24, `node --test` on `before/` reports 4 passed and 1 todo and exits 0: the todo test gets `404` where it expects `200`, and the run still counts as green. On `after/` all 5 tests pass.

A second edit has the same shape. A scratch copy such as `routes/invoices.old.js` is also listed at line 10, so `name` is `invoices.old` and the router is mounted at `/invoices.old` the next time the process starts. The directory contents are the program, and a file added by an editor or a merge changes the URL map without a line of code changing. The mount order is the order `readdirSync` returns the names; the Node page for `fs` is the place to check whether it states one, and this page does not claim it.

## 4. The fix

One file lists every router by a literal path and mounts it with a literal prefix. Each router file is unchanged. `app.js` mounts the table once.

```diff
--- before/app.js
+++ after/app.js
-const fs = require("node:fs");
-const path = require("node:path");
 const express = require("express");
 
+const routes = require("./routes");
+
 const app = express();
 app.use(express.json());
-
-// Every file in routes/ becomes a URL prefix named after the file.
-const routesDir = path.join(__dirname, "routes");
-for (const file of fs.readdirSync(routesDir)) {
-  const name = path.basename(file, ".js");
-  app.use("/" + name, require("./routes/" + file));
-}
+app.use(routes);
```

```diff
--- /dev/null
+++ after/routes/index.js
+const express = require("express");
+
+const customersRouter = require("./customers");
+const invoicesRouter = require("./invoices");
+const productsRouter = require("./products");
+
+const routes = express.Router();
+
+routes.use("/customers", customersRouter);
+routes.use("/invoices", invoicesRouter);
+routes.use("/products", productsRouter);
+
+module.exports = routes;
```

```diff
--- before/client/api.js
+++ after/client/api.js
-const getInvoice = (id) => fetchJson("/invoice/" + id);
+const getInvoice = (id) => fetchJson("/invoices/" + id);
```

## 5. Why the fix works

- **Every router has an importer.** `require("./invoices")` in `routes/index.js` is a string literal that a text search and a rename tool both find. Moving `routes/invoices.js` is followed by a tool that updates imports, and a missed one fails at startup: Node documents that `require()` throws a `MODULE_NOT_FOUND` error when the path does not exist.
- **The URL is a literal beside its router.** `routes.use("/invoices", invoicesRouter)` is the one place the public path is written. Renaming a file no longer renames a URL, and changing a URL is an edit to a line anyone can search for.
- **The prefix and the client can be checked against each other.** A search for `/invoices` lands on the mount line, the client and the test. The test at `after/tests/app.test.js` requests `/invoices/501` and asserts the total.
- **A stray file does nothing.** A scratch copy in `routes/` is not required by anything, so it adds no URL.
- **The cost is one line per router.** Adding a route file means adding two lines to `routes/index.js`. That edit is the record that a new URL is public, and the Express middleware guide's description of an app as a series of middleware calls means the line order in this file is also the order the routers are tried.

## 6. Rules that apply

- **AR-37**: do not load files with a computed path where an agent edits them. `require("./routes/" + file)` is the CommonJS form of the dynamic import in that rule.
- **AR-38**: register routes and middleware explicitly in one router module, with a literal import and a literal prefix per router.

## 7. References

Express (fetched 2026-10-30):
- Routing guide (`express.Router`, mounting with `app.use('/birds', birds)`, `mergeParams`): https://expressjs.com/en/guide/routing.html
- Using middleware (optional mount path, an app as a series of middleware calls): https://expressjs.com/en/guide/using-middleware.html
- FAQ, handling 404 responses (a 404 means no middleware or route responded): https://expressjs.com/en/starter/faq.html

Node.js (fetched 2026-10-30):
- Modules, `require(id)` (relative paths resolve against the calling file, `MODULE_NOT_FOUND` when the path does not exist, cached after first load): https://nodejs.org/api/modules.html
- File system, `fs.readdirSync`: https://nodejs.org/api/fs.html

## Rules it tests

- [AR-37: Why does my agent miss dynamically imported components?](https://beta.llmgraph.in/agent-readable/ar-37.md)
- [AR-38: How should Express routes be organised for coding agents?](https://beta.llmgraph.in/agent-readable/ar-38.md)

Last verified 2026-10-30
