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.