Skip to content

File-System Auto-Routing ​

Map directory structure to URL routes automatically. File paths become route paths; [param] directories become Express :param segments. Convention over configuration — no manual route registration.

When to Use ​

  • Building APIs where routes mirror a directory layout
  • Reducing boilerplate route registration
  • Wanting Next.js / Nuxt-style file routing in plain Express
  • Projects that benefit from discoverable routes (the file tree is the route table)

The Pattern ​

Scan a directory for handler files, convert each file path to a URL path, and mount it on an Express app. Mount the auto-router on an Express app by pointing it at a directory:

javascript
import express from "express";
import { autoRouter } from "./auto-router.js";
import { resolve } from "node:path";

const app = express();
const apiDir = resolve(import.meta.dirname, "api");
app.use(autoRouter(apiDir));
app.listen(3000);

Pass a custom glob pattern to mount only specific file types:

javascript
// Only mount JavaScript files
app.use(autoRouter(apiDir, {
  pattern: "**/*.js",
}));

Implementing autoRouter() ​

The autoRouter function scans for handler files, converts paths to routes, and returns a configured Express app:

javascript
import { join, parse, relative } from "node:path";
import { globSync } from "glob";
import express from "express";

const defaults = {
  pattern: "**/*.js",
};

export function autoRouter(dir = process.cwd(), options) {
  options = { ...defaults, ...options };
  const pattern = join(dir, options.pattern);
  const filePaths = globSync(pattern);
  
  const app = express();
  
  for (const filePath of filePaths) {
    const browserPath = toBrowserPath(filePath, dir);
    const handler = await import(filePath);
    app.all(browserPath, handler.default ?? handler);
  }
  
  return app;
}

Mapping convention ​

api/
├── index.js           →  /
├── login.js           →  /login
└── accounts/
    ├── index.js       →  /accounts/
    └── [id]/
        └── index.js   →  /accounts/:id/
  • index.js maps to the directory path
  • Named files map to their basename (no extension)
  • [param] directories become :param route segments

Each handler file exports a function that receives the standard Express request and response objects:

javascript
// api/accounts/[id]/index.js
export default function (req, res) {
  const { id } = req.params;
  res.json({ id, name: `Account ${id}` });
}

Path conversion ​

The toBrowserPath pipeline transforms a filesystem path into a URL route:

javascript
function toBrowserPath(filePath, dir) {
  let result = fileSystemToBrowserPath(filePath, dir);
  result = toForwardSlashes(result);
  result = addRouteParams(result);
  result = removeIndexAndExtension(result);
  return result;
}

function fileSystemToBrowserPath(filePath, root) {
  const rel = relative(root, filePath);
  return join("/", rel);
}

function toForwardSlashes(path) {
  return String(path).replace(/\\/g, "/");
}

function addRouteParams(path) {
  const parsed = parse(path);
  parsed.dir = parsed.dir
    .split("/")
    .map((segment) => {
      const match = /\[(?<name>.*)\]/.exec(segment);
      return match ? `:${match.groups.name}` : segment;
    })
    .join("/");
  return join(parsed.dir, parsed.base);
}

function removeIndexAndExtension(path) {
  const parsed = parse(path);
  if (parsed.base === "index.js") {
    return join(parsed.dir, "/");
  }
  return join(parsed.dir, parsed.name);
}

A parameterized index file traces through all four steps:

Input:  /Users/me/project/api/accounts/[id]/index.js
Root:   /Users/me/project/api

Step 1: /accounts/[id]/index.js
Step 2: /accounts/[id]/index.js  (no-op on Unix)
Step 3: /accounts/:id/index.js
Step 4: /accounts/:id/

A named file skips the parameter step and strips the extension:

Input:  /Users/me/project/api/login.js
Root:   /Users/me/project/api

Step 1: /login.js
Step 4: /login

On Windows, the slash normalization step is essential:

Input:  C:\project\api\users\[id]\profile.js
Root:   C:\project\api

Step 1: \users\[id]\profile.js
Step 2: /users/[id]/profile.js
Step 3: /users/:id/profile.js
Step 4: /users/:id/profile

Trade-offs ​

ApproachProsCons
FS routingDiscoverable, zero configImplicit
Manual routesExplicit, greppableBoilerplate
Config objectCentralizedIndirection

Works best for CRUD APIs with regular structure. For complex routing (regex, overlapping params), manual registration may be clearer. Zero-config defaults (process.cwd(), **/*.js) keep setup minimal.