Project Structure and Configuration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) with Express 4.22 differences called out where they matter, on Node.js 24 LTS (Express 5 itself needs Node.js 18 or later). All facts were checked on 2026-10-29. It is the page that answers "where does each piece of code go?" for an Express application: how to separate building the application from listening on a port, how to layer routes, controllers, services and data access, how the official mvc, route-separation and route-map examples organise code, how to read configuration from the environment and fail fast when it is wrong, and how to keep secrets out of Git. Express does not impose any of this. The framework is deliberately unopinionated, so the structure is a set of conventions that you choose and keep consistent.

The running scenario is the Bookshelf application that grows across this section. The final layout of the bookshelf-api project built on this page is the starting point of the pages on routers and modular apps, building REST APIs and testing.

Why structure matters in Express

A "hello world" application fits in one file, and the official documentation keeps examples that small on purpose. An application that lasts has to answer four questions, and Express leaves all of them to you:

  • Who creates the app object and who starts listening? If the file that builds the application also calls app.listen(), nothing can import it without opening a port, which makes tests slow and flaky.

  • Where does a request go after the router matches it? If handlers contain SQL, validation, business rules and response formatting, none of it can be reused or tested alone.

  • Where does configuration come from? If values are scattered across process.env reads and string literals, an unset variable shows up as a runtime crash or, worse, a silent insecure default.

  • Where do secrets live? A secret in the repository is a secret that is already leaked.

The sections below answer them in order, then compare the result with the official examples, the books and the generators.

The Bookshelf layout

The layout used on this page keeps the entry point, the application factory, the configuration and the three layers in separate folders. A folder per layer is the most common convention in Express projects, and it scales until a project has enough features to justify grouping by feature instead (see By layer or by feature).

bookshelf-api/
├── .env.example        # committed: names and placeholders only
├── .gitignore          # contains .env
├── package.json
├── src/
│   ├── server.js       # starts listening, handles shutdown signals
│   ├── app.js          # createApp(): builds and returns the Express app
│   ├── config.js       # reads and validates the environment
│   ├── errors.js       # HttpError and the error-handling middleware
│   ├── routes/
│   │   └── books.routes.js
│   ├── controllers/
│   │   └── books.controller.js
│   ├── services/
│   │   └── books.service.js
│   └── data/
│       ├── database.js
│       └── books.repository.js
└── test/
    └── books.test.js

Every request travels the same path. The router picks the handler, the controller translates HTTP into a call on the service, the service applies the rules, and the repository talks to the database. Dependencies point downwards only: a repository never imports a controller.

flowchart TB client["HTTP client"] server["server.js: listen, signals"] app["app.js: createApp"] mw["Middleware: json parser, logging"] routes["routes: URL and verb to handler"] controllers["controllers: HTTP in, HTTP out"] services["services: rules and use cases"] data["data: repository and database"] errors["error middleware: HttpError to problem JSON"] config["config.js: validated environment"] client --> server server --> app app --> mw mw --> routes routes --> controllers controllers --> services services --> data controllers -. "next(err) or rejected promise" .-> errors services -. "throws HttpError" .-> errors config -.-> server config -.-> app
Figure 1. Layers of the Bookshelf API

Express itself only provides the application, router and middleware machinery in the first four boxes. The layers below the router are plain JavaScript modules that do not import express, and that is the point: the less of your business code depends on req and res, the easier it is to test and to move.

Splitting the app from the server

Express builds an application with express(), and the returned app is just a request handler function. Calling app.listen() is shorthand for creating a node:http server with that handler and calling listen() on it. The two steps have different jobs and belong in different files:

  • app.js builds and exports the application: middleware, routes and error handlers. It performs no I/O at import time and never listens.

  • server.js is the process entry point: it loads the configuration, opens resources (the database), calls app.listen(), and wires SIGTERM and SIGINT to a clean shutdown.

The payoff is testability. A test can import the app, hand it to supertest, and drive it without a port, and a test can build the app with an in-memory database and a test configuration. The exported thing should be a factory function rather than a ready-made singleton, so each test gets a fresh application with its own dependencies.

The configuration module

The factory receives its configuration instead of reading process.env itself. The configuration module is covered in detail in Configuration from the environment; this version is enough to follow the code below.

// src/config.js
const NODE_ENVS = ['development', 'test', 'production'];

function requireString(env, name, { minLength = 1 } = {}) {
  const value = env[name];
  if (typeof value !== 'string' || value.length < minLength) {
    throw new Error(`Missing or too short environment variable ${name}`);
  }
  return value;
}

function parsePort(value) {
  const port = Number(value ?? 3000);
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
    throw new Error(`PORT must be an integer between 0 and 65535, got ${value}`);
  }
  return port;
}

export function loadConfig(env = process.env) {
  const nodeEnv = env.NODE_ENV ?? 'development';
  if (!NODE_ENVS.includes(nodeEnv)) {
    throw new Error(`NODE_ENV must be one of ${NODE_ENVS.join(', ')}, got ${nodeEnv}`);
  }
  const isProduction = nodeEnv === 'production';

  return Object.freeze({
    nodeEnv,
    isProduction,
    port: parsePort(env.PORT),
    databaseFile: env.DATABASE_FILE ?? ':memory:',
    // Required in production only, so local development works without a secret file.
    sessionSecret: isProduction
      ? requireString(env, 'SESSION_SECRET', { minLength: 32 })
      : (env.SESSION_SECRET ?? 'development-only-not-a-secret')
  });
}

The data layer

The data layer owns the connection and the SQL. This version uses the built-in node:sqlite module, the persistence used first in the Bookshelf scenario (see SQLite built into Node.js). At the check date the Node.js documentation marks node:sqlite as a release candidate (stability 1.2), so confirm its current status on nodejs.org before depending on it, and see Database integration for pg, mysql2 and MongoDB.

// src/data/database.js
import { DatabaseSync } from 'node:sqlite';

export function openDatabase(file) {
  const db = new DatabaseSync(file);
  db.exec(`
    CREATE TABLE IF NOT EXISTS books (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      author TEXT NOT NULL,
      year INTEGER
    )
  `);
  return db;
}
// src/data/books.repository.js
export function createBooksRepository(db) {
  const selectAll = db.prepare('SELECT id, title, author, year FROM books ORDER BY id');
  const selectOne = db.prepare('SELECT id, title, author, year FROM books WHERE id = ?');
  const insert = db.prepare('INSERT INTO books (title, author, year) VALUES (?, ?, ?)');
  const remove = db.prepare('DELETE FROM books WHERE id = ?');

  return {
    list: () => selectAll.all(),
    find: (id) => selectOne.get(id),
    create({ title, author, year = null }) {
      const result = insert.run(title, author, year);
      return selectOne.get(Number(result.lastInsertRowid));
    },
    delete: (id) => remove.run(id).changes > 0
  };
}

Source: Node.js: SQLite

The service layer

The service holds the rules. It knows nothing about HTTP except the error type, so the same service could be called from a job or a command-line tool.

// src/errors.js
export class HttpError extends Error {
  constructor(status, title, detail) {
    super(detail ?? title);
    this.name = 'HttpError';
    this.status = status;
    this.title = title;
  }
}

// Registered last in createApp(). The four parameters make it an error handler.
export function errorHandler(config) {
  return (err, req, res, next) => {
    if (res.headersSent) {
      // The response has already started, so delegate to the default handler,
      // which closes the connection.
      return next(err);
    }
    const status = err instanceof HttpError ? err.status : (err.status ?? err.statusCode ?? 500);
    const expose = status < 500 || !config.isProduction;
    res
      .status(status)
      .type('application/problem+json')
      .json({
        type: 'about:blank',
        title: err instanceof HttpError ? err.title : 'Internal Server Error',
        status,
        detail: expose ? err.message : undefined
      });
  };
}
// src/services/books.service.js
import { HttpError } from '../errors.js';

export function createBooksService(repository) {
  function parseId(raw) {
    const id = Number(raw);
    if (!Number.isInteger(id) || id < 1) {
      throw new HttpError(400, 'Bad Request', 'The book id must be a positive integer');
    }
    return id;
  }

  return {
    list: () => repository.list(),

    get(rawId) {
      const book = repository.find(parseId(rawId));
      if (!book) {
        throw new HttpError(404, 'Not Found', `Book ${rawId} does not exist`);
      }
      return book;
    },

    create(input) {
      const title = typeof input?.title === 'string' ? input.title.trim() : '';
      const author = typeof input?.author === 'string' ? input.author.trim() : '';
      if (!title || !author) {
        throw new HttpError(422, 'Unprocessable Content', 'title and author are required strings');
      }
      // Pick the allowed fields explicitly: never pass req.body straight to the data layer.
      return repository.create({ title, author, year: Number.isInteger(input.year) ? input.year : null });
    },

    remove(rawId) {
      if (!repository.delete(parseId(rawId))) {
        throw new HttpError(404, 'Not Found', `Book ${rawId} does not exist`);
      }
    }
  };
}

The explicit field picking in create matters: copying req.body straight into a model is the mass-assignment mistake that several of the books make (for example by taking a user role from the request body), and the service is the right place to prevent it. Validation libraries are covered in Validation and input handling.

The controller and the routes

A controller is the only layer that touches req and res. It reads parameters, calls the service, and chooses the status code and body. Because Express 5 forwards both a thrown error and a rejected promise from a handler to the error-handling middleware, the controller does not need a try/catch in each handler.

// src/controllers/books.controller.js
export function createBooksController(service) {
  return {
    list(req, res) {
      res.json(service.list());
    },

    show(req, res) {
      res.json(service.get(req.params.id));
    },

    create(req, res) {
      const book = service.create(req.body);
      res.status(201).location(`${req.baseUrl}/${book.id}`).json(book);
    },

    remove(req, res) {
      service.remove(req.params.id);
      res.status(204).end();
    }
  };
}

A routes module is a route-registration function: it receives its dependencies, builds an express.Router, and returns it. The application mounts it under a prefix, so the module does not need to know its own URL prefix.

// src/routes/books.routes.js
import express from 'express';

export function createBooksRouter(controller) {
  const router = express.Router();

  router.route('/')
    .get(controller.list)
    .post(controller.create);

  router.route('/:id')
    .get(controller.show)
    .delete(controller.remove);

  return router;
}

The application factory and the entry point

createApp is the composition root: the one place where concrete objects are created and connected. Nothing below it calls new on another layer.

// src/app.js
import express from 'express';
import { createBooksRepository } from './data/books.repository.js';
import { createBooksService } from './services/books.service.js';
import { createBooksController } from './controllers/books.controller.js';
import { createBooksRouter } from './routes/books.routes.js';
import { HttpError, errorHandler } from './errors.js';

export function createApp({ config, db }) {
  const app = express();

  app.disable('x-powered-by');

  app.use(express.json({ limit: '100kb' }));

  const repository = createBooksRepository(db);
  const service = createBooksService(repository);
  const controller = createBooksController(service);

  app.use('/api/v1/books', createBooksRouter(controller));

  // Anything that reached this point matched no route.
  app.use((req, res, next) => {
    next(new HttpError(404, 'Not Found', `${req.method} ${req.path} is not a route`));
  });

  app.use(errorHandler(config));

  return app;
}
// src/server.js
import { loadConfig } from './config.js';
import { openDatabase } from './data/database.js';
import { createApp } from './app.js';

const config = loadConfig();
const db = openDatabase(config.databaseFile);
const app = createApp({ config, db });

// Express 5: the callback receives the error if listening fails (for example EADDRINUSE).
const server = app.listen(config.port, (error) => {
  if (error) {
    console.error('Failed to start:', error);
    process.exit(1);
  }
  console.log(`bookshelf-api listening on port ${config.port} (${config.nodeEnv})`);
});

function shutdown(signal) {
  console.log(`${signal} received, shutting down`);
  server.close(() => {
    db.close();
    process.exit(0);
  });
  // Keep-alive connections would keep close() waiting, so drop the idle ones.
  server.closeIdleConnections();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
{
  "name": "bookshelf-api",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "start": "node --env-file=.env src/server.js",
    "dev": "node --watch --env-file=.env src/server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.3.0"
  },
  "devDependencies": {
    "supertest": "^7.0.0"
  }
}
npm install
cp .env.example .env
npm run dev
curl -i -X POST http://localhost:3000/api/v1/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"The Pragmatic Programmer","author":"Andrew Hunt","year":1999}'

The curl call returns 201 Created with a Location header and the stored book. Asking for an unknown book such as /api/v1/books/999 returns a 404 with a application/problem+json body produced by the error handler. Graceful shutdown is the subject of Health checks and graceful shutdown; the handler above shows only the minimum shape.

A test that imports the app

Because app.js never listens, a test can build an application with an in-memory database and call it with supertest, which binds the app to an ephemeral port on its own. The runner is the built-in node:test (see the Node.js test runner).

// test/books.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { loadConfig } from '../src/config.js';
import { openDatabase } from '../src/data/database.js';
import { createApp } from '../src/app.js';

function buildTestApp() {
  const config = loadConfig({ NODE_ENV: 'test' });
  const db = openDatabase(':memory:');
  return { app: createApp({ config, db }), db };
}

test('creates and reads a book', async (t) => {
  const { app, db } = buildTestApp();
  t.after(() => db.close());

  const created = await request(app)
    .post('/api/v1/books')
    .send({ title: 'Refactoring', author: 'Martin Fowler', year: 1999 })
    .expect(201);

  const fetched = await request(app).get(created.headers.location).expect(200);
  assert.equal(fetched.body.title, 'Refactoring');
});

test('answers 404 as problem JSON for an unknown book', async (t) => {
  const { app, db } = buildTestApp();
  t.after(() => db.close());

  const response = await request(app).get('/api/v1/books/999').expect(404);
  assert.match(response.headers['content-type'], /application\/problem\+json/);
  assert.equal(response.body.status, 404);
});

The configuration is built from an explicit object, loadConfig({ NODE_ENV: 'test' }), instead of the real process environment, so a test never depends on what happens to be exported in the developer’s shell. The same idea reappears in Testing.

The older way and the ESM alternative

Older Express code avoids listening during tests with a check such as "only listen when this file is the entry point". The official mvc and route-separation examples do this with the CommonJS module.parent property, and the express-generator skeleton (see Generators and boilerplates) solves it by putting the server in a separate bin/www script. ES modules have no module.parent. A recent Node.js release line offers import.meta.main for "is this the entry file", and its availability depends on the Node.js version, so check nodejs.org before relying on it. The factory plus a separate server.js works on every Node.js version Express 5 supports and needs no such check.

Routes, controllers, services and data

The layering above has four layers, and each has one reason to change.

Layer Responsibility Must not do

Routes

Map a verb and a path to a handler; attach route-level middleware such as authentication or validation; define the URL space.

Contain business rules or SQL.

Controllers

Read req (params, query, body, headers, user), call one service method, choose the status code, headers and body.

Hold state, open connections, or contain rules that a second entry point (a job, a CLI) would also need.

Services

Implement use cases and rules, coordinate repositories, throw domain or HttpError errors.

Import express, read req or res, or know about status codes beyond a thin error type.

Data

Hide the database: queries, transactions, mapping rows to objects.

Leak driver types or SQL fragments upwards, or apply business rules.

Three guidelines keep the layering useful rather than ceremonial:

  • Do not add a layer that does nothing. In a small CRUD application the service may be a thin pass-through. That is acceptable, and it still earns its keep as soon as the first rule appears. Skipping it entirely is also reasonable for a prototype, as long as controllers stay small.

  • Pass dependencies in. Factories like createBooksService(repository) make the dependency graph explicit and let a test substitute a fake repository. A dependency-injection container is optional; NestJS (see Providers and dependency injection) is the framework to look at if you want one built in.

  • Keep Express objects at the edge. Passing req or res to a service is the most common way the layers collapse. Pass the values the service needs instead.

By layer or by feature

The layout above groups files by layer (controllers/, services/). When an application grows to a dozen resources the folders get long and a change to "reviews" touches four directories. The alternative is to group by feature: one folder per resource (books/, reviews/, authors/) holding its routes, controller, service and repository, plus a shared folder for cross-cutting code. Both are valid. Express does not care, because each feature folder exports a router factory that createApp mounts. Pick one and apply it consistently, and move to the feature layout when features start to have their own lifecycle. The mounting mechanics are covered in Routers and modular apps.

MVC for server-rendered applications

Model-view-controller is the layering that server-rendered Express applications (the style of the Node.js tutorials of the 2010s) grew around. The mapping onto the layers above is direct:

  • the model is the data layer plus the rules in the service layer;

  • the view is a template rendered with res.render() (see Template engines and views);

  • the controller is the same controller as in the API layering, except that it calls res.render('books/index', { books }) instead of res.json(books).

A JSON API has no view layer, which is why API projects usually say "routes, controllers, services" and skip the word MVC. A server-rendered admin area for the Bookshelf uses the same service and repository, and only the controller and the views differ.

// src/controllers/admin-books.controller.js
export function createAdminBooksController(service) {
  return {
    index(req, res) {
      res.render('admin/books/index', { title: 'Books', books: service.list() });
    },

    show(req, res) {
      const book = service.get(req.params.id);
      res.render('admin/books/show', { title: book.title, book });
    },

    create(req, res) {
      service.create(req.body);
      res.redirect(303, '/admin/books');
    }
  };
}
<!-- views/admin/books/index.ejs -->
<h1><%= title %></h1>
<ul>
  <% for (const book of books) { %>
    <li><a href="/admin/books/<%= book.id %>"><%= book.title %></a> by <%= book.author %></li>
  <% } %>
</ul>
// in createApp(): view engine, views folder, and the form body parser
import path from 'node:path';

app.set('view engine', 'ejs');
app.set('views', path.join(import.meta.dirname, '..', 'views'));
app.use(express.urlencoded({ extended: true }));
app.use('/admin/books', createAdminBooksRouter(adminController));

The <%= %> tag HTML-escapes its value. The unescaped <%- %> tag is for trusted markup only, and using it for user input or for error messages and stack traces is a cross-site scripting hole. import.meta.dirname is available in the Node.js versions this page targets and replaces the __dirname of CommonJS. Redirecting with 303 See Other after a successful form post prevents the browser from resubmitting the form on reload. The ejs package is installed with npm install ejs.

The official mvc, route-separation and route-map examples

The expressjs/express repository ships runnable examples under examples/. Three of them are about structure. They show three different answers to "how do I stop app.js from becoming one long file?", and they are worth reading with the caveat that they are written in CommonJS and kept deliberately small.

route-separation

The route-separation example keeps app.get(...) registrations in one place but moves the handler functions into modules named after the resource (site, post, user). The main file is a readable table of the application’s URL space, and each module exports plain functions with the (req, res) or (req, res, next) signature. Its user module also shows the load pattern: a handler registered with app.all() for /user/:id looks the user up, stores it on the request, and calls next(), so the later handlers for viewing and editing can assume it is there; if the lookup fails, it calls next(err) with a 404 error.

In Express 5 the optional segment in such a route is written with braces, for example /user/:id{/:op}, where Express 4 wrote /user/:id/:op?. The later page Route paths and parameters covers the full syntax. The same idea in the Bookshelf layering is a router.param('id', ...) callback or a small loadBook middleware that every /:id route runs first.

// src/routes/books.routes.js (variant with a load middleware)
import express from 'express';

export function createBooksRouter(controller, service) {
  const router = express.Router();

  // Runs for every route that has an :id parameter in this router.
  router.param('id', (req, res, next, id) => {
    req.book = service.get(id); // throws HttpError(404) when missing; Express 5 forwards it
    next();
  });

  router.get('/', controller.list);
  router.get('/:id', (req, res) => res.json(req.book));
  router.delete('/:id', controller.remove);

  return router;
}

mvc

The mvc example goes furthest. A small boot module reads the controllers directory at start-up. Each subdirectory is one controller whose module exports functions with conventional names (for example list, show, edit, update and create) and a few reserved exports for the name, the URL prefix, the view engine and a before filter. The boot code turns each exported function into a route on a sub-application: it creates a new express() app per controller, gives it its own views directory, mounts it on the parent application and prints the generated routes. Models live in a separate module and a shared lib folder holds the boot code. Because each controller is a mounted sub-app, it can set a different view engine from its neighbours.

What to take from it:

  • Convention over registration. Routes come from file and export names, so adding a controller needs no edit to a central routing file. The cost is that routes are no longer greppable in one place, which is why most teams prefer explicit routers.

  • Sub-applications for isolation. A mounted express() app has its own settings (views, view engine) and its own mountpath. See Routers and modular apps for the difference between a sub-app and an express.Router.

  • Sessions and flash messages. The example adds a res.message() method and exposes the stored messages to every view through res.locals. It does so by assigning a function to app.response, the prototype of all response objects, which is the supported way to extend res (see Extending the Express API).

The same convention-driven registration is a short function in ESM, in a form that keeps explicit control of the verbs. This is an original sketch for the Bookshelf, not the example’s code:

// src/routes/register-controllers.js
import express from 'express';

// Maps conventional method names to [verb, path] pairs, relative to the controller mount point.
const CONVENTION = {
  list: ['get', '/'],
  show: ['get', '/:id'],
  create: ['post', '/'],
  update: ['put', '/:id'],
  remove: ['delete', '/:id']
};

export function registerControllers(app, controllers) {
  for (const [prefix, controller] of Object.entries(controllers)) {
    const router = express.Router();
    for (const [method, [verb, path]] of Object.entries(CONVENTION)) {
      if (typeof controller[method] === 'function') {
        router[verb](path, controller[method]);
      }
    }
    app.use(prefix, router);
  }
}

// usage in createApp():
//   registerControllers(app, { '/api/v1/books': booksController, '/api/v1/authors': authorsController });

route-map

The route-map example defines the whole URL space as a nested object. A small app.map() helper walks it recursively: object keys that start with a slash extend the current path, and keys that are verbs (get, delete, …​) hold the handler functions. The result reads like a table of contents of the API. It is a good illustration of how little Express itself requires: a "router" is only a function that calls app.get, app.post and so on.

// src/routes/route-map.js
import express from 'express';

const VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'query']);

// Registers every verb found in a nested { '/path': { get: fn, '/child': { ... } } } object.
export function mapRoutes(router, tree, base = '') {
  for (const [key, value] of Object.entries(tree)) {
    if (typeof value === 'function' && VERBS.has(key)) {
      router[key](base || '/', value);
    } else if (value && typeof value === 'object' && key.startsWith('/')) {
      // Drop a trailing slash before joining, so '/' + '/:id' becomes '/:id', not '//:id'
      mapRoutes(router, value, base.replace(/\/$/, '') + key);
    } else {
      throw new TypeError(`Unsupported route map entry "${key}" under "${base}"`);
    }
  }
  return router;
}

export function createBooksMapRouter(controller) {
  return mapRoutes(express.Router(), {
    '/': {
      get: controller.list,
      post: controller.create,
      '/:id': {
        get: controller.show,
        delete: controller.remove
      }
    }
  });
}

Registering through a helper like this is a style choice. Plain router.route('/:id').get(...).delete(...) (shown in the routes module earlier) is usually clearer to readers and tools, and has no custom code to maintain.

What the official examples do not show

The examples are teaching material, and they carry shortcuts that a real project must not copy. The mvc example, for instance, passes a literal string as the session secret, which is the exact pattern the configuration sections below exist to avoid. The examples also use var, CommonJS and the module.parent check, none of which is the recommended style for new code, and they do not include an error-handling layer beyond the basics. Treat them as a map of ideas, not a template.

Configuration from the environment

Configuration is everything that changes between deployments: the port, the database location, credentials, feature flags, the log level. The widely followed rule (the twelve-factor app methodology) is to store it in environment variables, read it once at start-up, and keep it out of the code. Environment variables are a flat set of strings, never typed values, so the application has to parse and validate them.

NODE_ENV and app.get('env')

Express reads process.env.NODE_ENV once, when the application is created, and exposes it as the env setting:

  • app.get('env') returns the value of NODE_ENV, or 'development' when it is unset.

  • Express treats only the exact value production as production. Any other string, including staging, behaves like development. Define your own stages through your own variables, and keep NODE_ENV to development, test and production.

  • In production the view cache setting is enabled, which caches compiled templates, and the default error handler no longer prints the stack trace in the response body. Many libraries also read NODE_ENV, for example to disable debug output or to enable optimisations, so setting it to production in deployed environments matters for performance as well as security. The performance page of the official documentation lists it as one of the simplest improvements.

// Reading the environment through the app, as the official documentation does.
import express from 'express';

const app = express();

console.log(app.get('env'));            // 'development' unless NODE_ENV is set
console.log(app.get('view cache'));     // true only when NODE_ENV=production
app.set('env', 'production');           // possible but unusual: prefer setting NODE_ENV before start-up

Prefer the validated config object from A validated config module with fail-fast behaviour over reading app.get('env') in application code, so the whole application sees one consistent source. app.get('env') remains useful for code that is already inside an Express handler and for third-party middleware that checks it.

Loading .env files: --env-file, loadEnvFile and dotenv

During development it is convenient to keep variables in a .env file rather than exporting them in every shell. There are three ways to load one, and a modern project needs no extra dependency for the first.

Approach How it works Notes

node --env-file=.env

The Node.js launcher parses the file before the first line of your code runs. The flag can be repeated, and later files override earlier ones. A variable that already exists in the real environment is not overridden by the file.

No dependency. The --env-file-if-exists flag variant does not fail when the file is missing, which suits optional local overrides. Works with --watch and in package.json scripts. The flag is part of the Node.js command line, so it does not apply to a bundler or a process manager that starts Node.js differently.

process.loadEnvFile() and util.parseEnv()

process.loadEnvFile(path) loads a file into process.env from inside the program. util.parseEnv(text) parses text and returns an object without touching the environment.

Useful in tests and scripts, and parseEnv is handy for validating an example file against the real one. Loading from code happens after imports run, so anything read at import time does not see it.

The dotenv package

A library that parses .env files and populates process.env, loaded with an import such as import 'dotenv/config' before the rest of the code.

Still very common in older code and tutorials. Needed on Node.js versions without the built-in flag, or for options such as encrypted files. Check its documentation for its current options and defaults before relying on them.

The recommendation for new projects is the flag in the start and dev scripts (as in the package.json above) plus a real environment in production. In production the platform (a container orchestrator, a PaaS dashboard, a systemd unit) injects the variables, and no .env file is deployed at all. That is why the application must never require a file to exist: if --env-file is omitted, the variables simply come from the environment.

# development: variables from the file
node --watch --env-file=.env src/server.js

# production: variables come from the platform, no file involved
NODE_ENV=production PORT=8080 node src/server.js

# optional local overrides on top of the shared file, ignored when missing
node --env-file=.env --env-file-if-exists=.env.local src/server.js

A validated config module with fail-fast behaviour

The config.js shown earlier is the pattern: one module reads process.env once, converts each value to its type, applies defaults only where a default is safe, and throws at start-up if something required is missing or malformed. Failing at boot beats failing under load: the orchestrator sees a crashed container immediately, instead of a healthy-looking process that fails on the first request that needs the missing value.

Rules that keep the module trustworthy:

  • One reader. Only config.js touches process.env. Everything else receives the frozen configuration object, which is also what makes createApp({ config }) easy to test.

  • No insecure defaults for secrets. A development fallback is fine only when the code refuses to run it in production, as in the isProduction check above. A default such as secret = 'changeme' ships to production sooner or later.

  • Parse, don’t cast. Number(process.env.PORT) yields NaN for a typo, and 'false' is a truthy string. Convert and validate each value.

  • Report all problems at once. The sketch above throws on the first problem for brevity. A real module can collect every error into one message so operators fix them in a single pass.

  • Never log secrets. When printing the effective configuration at start-up, list the names and non-secret values only.

Schema libraries (Zod, Joi, Ajv, or envalid) express the same checks declaratively. They are worth adopting when the list of variables grows; the principle is the same. Zod and Joi appear again in Validation and input handling.

// A start-up check that names every missing variable at once.
export function assertEnv(env, names) {
  const missing = names.filter((name) => !env[name]);
  if (missing.length > 0) {
    throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
  }
}

// src/server.js: assertEnv(process.env, ['DATABASE_FILE', 'SESSION_SECRET']);

Keeping secrets out of Git

A secret is any value that grants access: session and JWT signing keys, database passwords, API tokens, OAuth client secrets, webhook signing secrets. The rules are short, and breaking them is the most common security mistake in Express tutorials:

  • Never commit a .env file. Put .env and its variants in .gitignore before the first commit. Deleting a file later does not remove it from history.

  • Commit an example file. .env.example lists every variable the application reads, with placeholders and comments, and no real value. It documents the contract and lets a new developer copy it to .env.

  • Generate secrets, do not invent them. A signing key should be random and long enough for its algorithm.

  • Use a different secret per environment. The staging key must not open production.

  • Rotate after exposure. If a secret reached Git, a log, a screenshot or a chat, treat it as leaked: revoke and replace it. Rewriting history does not undo what clones, forks and caches already hold.

  • Scan for leaks. Run a secret scanner in a pre-commit hook and in CI, so a mistake is caught before it is pushed.

  • In production, use the platform. Container and orchestrator secrets, a cloud secret manager, or the PaaS configuration screen inject the variables at run time. The image must not contain them.

# .gitignore additions
printf '%s\n' '.env' '.env.*' '!.env.example' >> .gitignore

# generate a session secret of 32 random bytes (64 hex characters)
node -p "require('node:crypto').randomBytes(32).toString('hex')"
# .env.example  (committed; placeholders only)
NODE_ENV=development
PORT=3000
DATABASE_FILE=./bookshelf.db

# Generate with: node -p "require('node:crypto').randomBytes(32).toString('hex')"
SESSION_SECRET=replace-with-a-generated-64-character-hex-string

The .gitignore pattern list ignores every .env variant and then re-includes .env.example; the order matters because a later negation overrides an earlier ignore.

For the cryptographic side of generating and storing secrets, see Crypto, passwords and secrets in Node.js; for how secrets feed the session and cookie options, see Cookies and sessions; and for the wider checklist, Security best practices.

Generators and boilerplates

Before npm init and a few files were considered enough, projects began from a generator. Three of them recur in older tutorials and in the books, and all three are historical for new work.

express-generator

express-generator is the official command-line tool of the expressjs/generator repository. You run it with npx express-generator (or install it and run its express command) to create an application skeleton. At the check date its npm version is 4.16.1 and it is the only version on the latest tag, so it still produces an Express 4-era project. The official page documents these options:

Option Effect

-v, --view <engine>

Adds support for a view engine: ejs, hbs, hjs, jade, pug, twig or vash. The default is jade. Shortcuts -e (ejs), --hbs, --pug and -H (hogan.js) exist.

--no-view

Generates the skeleton without a view engine.

-c, --css <engine>

Adds stylesheet support for less, stylus, compass or sass. The default is plain CSS.

--git

Adds a .gitignore.

-f, --force

Forces generation into a non-empty directory.

The generated tree has app.js, a bin/www script, package.json, public/ (images, javascripts, stylesheets), routes/ (index.js and users.js) and views/. The official page itself notes that this structure is only one of many ways to organise an Express application. Its bin/www script is the original form of the app/server split: app.js exports the application and bin/www creates the HTTP server, reads the port from the environment, handles listen errors and starts listening. The split recommended on this page is the same idea in modern clothes.

What is dated in the output:

  • the dependency list pins an Express 4 version, so the scaffold needs npm install express@5 and a review of every generated file against the Express 5 changes;

  • it uses CommonJS, var and callbacks, and the default view engine is Jade, which was renamed Pug in 2016;

  • routes are registered in files that export a router, but there is no service or data layer, no configuration module and no error handling beyond the default;

  • the generated application is not an ES module, so the factory and node: prefixes of this page are manual edits.

Modernising the output means: choose --no-view or --view=pug/ejs deliberately, run npm install express@5, convert to ESM if wanted, replace the generated bin/www with a short server.js, move the application into a createApp() factory, add the configuration module and .gitignore entries, and delete anything unused. For a new project, hand-writing the few files is usually faster than cleaning up the scaffold.

Yeoman generator-express, MEAN.JS and similar

Community generators and full-stack boilerplates fill the gap that Express leaves. The Yeoman generator-express and the MEAN stack boilerplates (MEAN.JS and its relatives) scaffolded an Express server together with a front-end framework, a database and a build chain (Grunt, Gulp or Bower in their time). The ecosystems around them have mostly been archived, renamed or left behind: AngularJS, Bower, Grunt-based builds and Yeoman itself are no longer how projects start. They remain useful as reading material for how people structured applications, not as starting points.

The modern equivalents are smaller and more explicit: the layout on this page, a framework starter for the front end (see SPAs and server-side rendering), or a framework that decides the structure for you, such as NestJS (see the NestJS section) with its CLI. Getting started covers installing and trying the generator step by step.

What changed recently

  • Express 5.0 (September 2024) and 5.1 (2025-03-31, first on latest). app.listen() passes errors to its callback, rejected promises from handlers reach the error handlers, req.body is undefined when no body parser ran, and the path syntax moved to path-to-regexp 8 (router 2.x), which is why the route strings in older structure tutorials need rewriting. Together these simplify the controller and server.js code on this page.

  • Express 5.2 and 5.3. Maintenance and security releases; 5.3.0 (2026-10-09) adds support for the QUERY method through app.query() (see Routing). Check Versions, support and LTS for the support status of 4.x and 5.x before choosing a line.

  • Node.js. The built-in --env-file, --env-file-if-exists, process.loadEnvFile(), util.parseEnv() and node:sqlite removed the need for dotenv and a native SQLite binding in many small applications, and --watch replaced nodemon for development.

  • express-generator. Still 4.16.1 on npm, so it did not follow Express 5.

Where the books are outdated

The four reference books were consulted for the concepts of this page (the generator skeleton and bin/www, MVC layering, route-registration functions, configuration through the environment, and the importable app). They are all written for Express 4 (4.0 to 4.18) and none can show Express 5. The statements below are corrected, not reproduced.

What older material does Status today What to do instead

Installs Express globally to get a command-line tool (Sharma ch. 2, Monteiro 2016 ch. 1)

The generator is the separate express-generator package and Express itself has no CLI. The generator is still 4.16.1 and Jade-default.

Install express locally, run npx express-generator only to inspect a scaffold, and write the structure above by hand.

Builds on the generated bin/www, var and CommonJS (Sharma, both Monteiro books)

Works with Express 5, but is the older style.

A createApp() factory in app.js and a short server.js, in ESM.

Starts the server in the same file that creates the app, so tests import a module that calls listen() (Sharma ch. 9)

Opens a port per test file and can fail with address-in-use errors.

Export a factory and listen only from server.js; hand the app to supertest.

Layout and conventions tied to Express 4.0 to 4.2 and AngularJS 1.2 (Monteiro 2014)

AngularJS, Bower, Grunt and the tooling of that era are end of life or archived.

Keep the Express structure and replace the front end (see the pages on SPAs and SSR).

Hard-coded secrets and committed .env files (Sharma, both Monteiro books, and the companion repository of Bugl)

Insecure. A secret in Git history is compromised.

Ignore .env, commit .env.example, generate secrets, validate at start-up, rotate after exposure.

Loads .env with dotenv as the only option (Bugl)

Still works, but Node.js has --env-file and process.loadEnvFile().

Prefer the built-in flag; keep dotenv only where the Node.js version requires it.

Passes the body parser as a separate body-parser package, or app.use(bodyParser()) (all four books)

The one-call form no longer exists; Express has built in express.json() and express.urlencoded(). In Express 5 urlencoded defaults to extended: false.

Use express.json() and express.urlencoded().

Calls app.listen without handling a failure to listen (Bugl)

Express 5 passes the error to the listen callback.

Check the error argument, as in server.js above.

Wraps each async handler in try/catch with no central handler, or writes if (err) res.send(err) without return

Express 5 forwards rejected promises and thrown errors; a missing return sends two responses and fails with ERR_HTTP_HEADERS_SENT.

One error handler registered last; handlers throw or reject.

Blocking hash or database calls inside controllers, such as synchronous password hashing (Monteiro books)

Blocks the event loop for every user.

Use the asynchronous API from the service layer.

Pitfalls

  • A circular import between layers. A service importing a controller (to reuse a helper) makes start-up order-dependent. Move shared code to a neutral module.

  • Reading process.env at import time in many modules. The value is frozen before a test or --env-file has had a chance to set it. Read it once, in loadConfig().

  • Registering error handlers before routes. The error handler must come last, or it never receives the errors of later routes. The 404 handler also comes after all real routes.

  • Singletons. Exporting const app = express() directly, with a module-level database, forces every test to share state.

  • Creating the database connection inside createApp. Open it in server.js (or the test) and pass it in, so the owner of the connection is also the one that closes it.

  • Putting NODE_ENV=staging in a deployment. Express and most libraries only recognise production, so staging silently runs in development mode. Use a separate variable for the stage name.

  • Shipping the .env file in a Docker image. A COPY . . without a .dockerignore entry copies it. Ignore .env there too (see Deployment and containers).