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
appobject and who starts listening? If the file that builds the application also callsapp.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.envreads 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
Source: Express: Express examples
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.
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.jsbuilds and exports the application: middleware, routes and error handlers. It performs no I/O at import time and never listens. -
server.jsis the process entry point: it loads the configuration, opens resources (the database), callsapp.listen(), and wiresSIGTERMandSIGINTto 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')
});
}
Source: Node.js: process.env
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`);
}
}
};
}
Source: Express: Error handling
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;
}
Source: Express: Routing
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.
Source: Express: Hello world
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);
});
Source: supertest; Node.js: Test runner
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 |
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 |
Import |
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
reqorresto 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 ofres.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.
Source: Express: Using template engines
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 ownmountpath. See Routers and modular apps for the difference between a sub-app and anexpress.Router. -
Sessions and flash messages. The example adds a
res.message()method and exposes the stored messages to every view throughres.locals. It does so by assigning a function toapp.response, the prototype of all response objects, which is the supported way to extendres(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 ofNODE_ENV, or'development'when it is unset. -
Express treats only the exact value
productionas production. Any other string, includingstaging, behaves like development. Define your own stages through your own variables, and keepNODE_ENVtodevelopment,testandproduction. -
In production the
view cachesetting is enabled, which caches compiled templates, and the default error handler no longer prints the stack trace in the response body. Many libraries also readNODE_ENV, for example to disable debug output or to enable optimisations, so setting it toproductionin 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 |
|---|---|---|
|
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 |
|
|
Useful in tests and scripts, and |
The |
A library that parses |
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.jstouchesprocess.env. Everything else receives the frozen configuration object, which is also what makescreateApp({ 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
isProductioncheck above. A default such assecret = 'changeme'ships to production sooner or later. -
Parse, don’t cast.
Number(process.env.PORT)yieldsNaNfor 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']);
Source: The Twelve-Factor App: Config
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
.envfile. Put.envand its variants in.gitignorebefore the first commit. Deleting a file later does not remove it from history. -
Commit an example file.
.env.examplelists 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 |
|---|---|
|
Adds support for a view engine: |
|
Generates the skeleton without a view engine. |
|
Adds stylesheet support for |
|
Adds a |
|
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@5and a review of every generated file against the Express 5 changes; -
it uses CommonJS,
varand 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.bodyisundefinedwhen no body parser ran, and the path syntax moved to path-to-regexp 8 (router2.x), which is why the route strings in older structure tutorials need rewriting. Together these simplify the controller andserver.jscode on this page. -
Express 5.2 and 5.3. Maintenance and security releases; 5.3.0 (2026-10-09) adds support for the
QUERYmethod throughapp.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()andnode:sqliteremoved the need fordotenvand a native SQLite binding in many small applications, and--watchreplacednodemonfor 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 |
Install |
Builds on the generated |
Works with Express 5, but is the older style. |
A |
Starts the server in the same file that creates the app, so tests import a module that calls |
Opens a port per test file and can fail with address-in-use errors. |
Export a factory and listen only from |
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 |
Insecure. A secret in Git history is compromised. |
Ignore |
Loads |
Still works, but Node.js has |
Prefer the built-in flag; keep |
Passes the body parser as a separate |
The one-call form no longer exists; Express has built in |
Use |
Calls |
Express 5 passes the error to the |
Check the |
Wraps each async handler in |
Express 5 forwards rejected promises and thrown errors; a missing |
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.envat import time in many modules. The value is frozen before a test or--env-filehas had a chance to set it. Read it once, inloadConfig(). -
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 inserver.js(or the test) and pass it in, so the owner of the connection is also the one that closes it. -
Putting
NODE_ENV=stagingin a deployment. Express and most libraries only recogniseproduction, so staging silently runs in development mode. Use a separate variable for the stage name. -
Shipping the
.envfile in a Docker image. ACOPY . .without a.dockerignoreentry copies it. Ignore.envthere too (see Deployment and containers).