This is the structure I reach for on medium-sized Node backends. For something small, a single file is usually enough, and I wouldn't bother wiring up build tooling at all.

Package manifest and toolchain

Everything starts in package.json:

{
	"main": "index.js",
	"engines": {
		"node": "12.18.2"
	},
	"dependencies": {
		"express": "^4.17.1",
		"express-async-errors": "^3.1.1",
		"loglevel": "^1.6.8"
	},
	"devDependencies": {
		"@babel/cli": "^7.10.4",
		"@babel/core": "^7.10.4",
		"@babel/preset-env": "^7.10.4",
		"@babel/register": "^7.10.4",
		"nodemon": "^2.0.4"
	},
	"scripts": {
		"start": "node .",
		"build": "babel --delete-dir-on-start --out-dir dist --copy-files --ignore \"**/__tests__/**,**/__mocks__/**\" --no-copy-ignored src"
	}
}

Entry point and Node version

main names the file that runs when you execute node . in the project directory. engines tells our tooling which Node version the project targets.

Runtime dependencies

express is a given, though alternatives work too and much of this still applies. Alongside it I always install express-async-errors, which lets middleware be written with async/await; it propagates async errors to the error-handling middleware, so the code is far less error prone. For logging, loglevel is my preference — plenty of other options exist.

Development dependencies

Babel handles compilation, letting us use syntax the runtime doesn't yet support (ESModules, mostly) plus plugins such as babel-plugin-macros. The relevant packages:

  • @babel/core — required by everything else.
  • @babel/cli — drives the build script.
  • @babel/preset-env — bundles the language plugins and transforms needed for the target environment.
  • @babel/register — used in development.

TypeScript users should add @babel/preset-typescript. nodemon covers watch mode by restarting the server on file changes.

Scripts

start runs node ., which lands on main (index.js). The build script compiles everything in src with Babel into dist:

  • --delete-dir-on-start — no stale files left between builds
  • --out-dir dist — where compiled output goes
  • --copy-files — non-compiled files (e.g. .json) get copied instead
  • --ignore "**/__tests__/**,**/__mocks__/**" — skip test files, which aren't needed in production
  • --no-copy-ignored — since ignored files aren't compiled, don't copy them either

With TypeScript, add --extensions ".ts,.tsx,.js" to the build script.

Babel configuration

The .babelrc.js stays minimal:

const pkg = require('./package.json')

module.exports = {
	presets: [
		[
			'@babel/preset-env',
			{
				targets: {
					node: pkg.engines.node,
				},
			},
		],
	],
}

All code is compiled down to the syntax supported by the engines.node value from package.json. Teams using TypeScript would add @babel/preset-typescript here too.

Environment-dependent entry points

index.js is the module entry — the main field above:

if (process.env.NODE_ENV === 'production') {
	require('./dist')
} else {
	require('nodemon')({ script: 'dev.js' })
}

In production, the server sets NODE_ENV to 'production', so this starts the compiled output. Locally it instead requires nodemon with {script: 'dev.js'}, which runs that script and re-runs it on changes, tightening the feedback loop. nodemon takes many more options, and node-dev was suggested to me as another project worth a look.

The dev script itself is short:

require('@babel/register')
require('./src')

@babel/register compiles files on the fly as they're required, before Node runs them. Then require('./src') pulls in src/index.js, where the real work begins.

Application wiring

src/index.js only configures the logger and starts the server:

import logger from 'loglevel'
import { startServer } from './start'

logger.setLevel('info')

startServer()

Most projects kick the server off right there, but I extract that logic into a function because it's easier to test. That function lives in src/start.js, where things get properly "expressy" — the code below explains itself:

import express from 'express'

// this is all it takes to enable async/await for express middleware
import 'express-async-errors'

import logger from 'loglevel'

// all the routes for my app are retrieved from the src/routes/index.js module
import { getRoutes } from './routes'

function startServer({ port = process.env.PORT } = {}) {
	const app = express()

	// I mount my entire app to the /api route (or you could just do "/" if you want)
	app.use('/api', getRoutes())

	// add the generic error handler just in case errors are missed by middleware
	app.use(errorMiddleware)

	// I prefer dealing with promises. It makes testing easier, among other things.
	// So this block of code allows me to start the express app and resolve the
	// promise with the express server
	return new Promise((resolve) => {
		const server = app.listen(port, () => {
			logger.info(`Listening on port ${server.address().port}`)

			// this block of code turns `server.close` into a promise API
			const originalClose = server.close.bind(server)
			server.close = () => {
				return new Promise((resolveClose) => {
					originalClose(resolveClose)
				})
			}

			// this ensures that we properly close the server when the program exists
			setupCloseOnExit(server)

			// resolve the whole promise with the express server
			resolve(server)
		})
	})
}

// here's our generic error handler for situations where we didn't handle
// errors properly
function errorMiddleware(error, req, res, next) {
	if (res.headersSent) {
		next(error)
	} else {
		logger.error(error)
		res.status(500)
		res.json({
			message: error.message,
			// we only add a `stack` property in non-production environments
			...(process.env.NODE_ENV === 'production'
				? null
				: { stack: error.stack }),
		})
	}
}

// ensures we close the server in the event of an error.
function setupCloseOnExit(server) {
	// thank you stack overflow
	// https://stackoverflow.com/a/14032965/971592
	async function exitHandler(options = {}) {
		await server
			.close()
			.then(() => {
				logger.info('Server successfully closed')
			})
			.catch((e) => {
				logger.warn('Something went wrong closing the server', e.stack)
			})

		if (options.exit) process.exit()
	}

	// do something when app is closing
	process.on('exit', exitHandler)

	// catches ctrl+c event
	process.on('SIGINT', exitHandler.bind(null, { exit: true }))

	// catches "kill pid" (for example: nodemon restart)
	process.on('SIGUSR1', exitHandler.bind(null, { exit: true }))
	process.on('SIGUSR2', exitHandler.bind(null, { exit: true }))

	// catches uncaught exceptions
	process.on('uncaughtException', exitHandler.bind(null, { exit: true }))
}

export { startServer }

The payoff is testability. An integration test can be as simple as this:

import { startServer } from '../start'

let server, baseURL
beforeAll(async () => {
	server = await startServer()
	baseURL = `http://localhost:${server.address().port}/api`
})

afterAll(() => server.close())

// make requests to the baseURL

Route assembly

All routes meet in src/routes/index.js:

import express from 'express'
// any other routes imports would go here
import { getMathRoutes } from './math'

function getRoutes() {
	// create a router for all the routes of our app
	const router = express.Router()

	router.use('/math', getMathRoutes())
	// any additional routes would go here

	return router
}

export { getRoutes }

And src/routes/math.js is a contrived example of routes, middleware and Express controllers:

import express from 'express'

// A function to get the routes.
// That way all the route definitions are in one place which I like.
// This is the only thing that's exported
function getMathRoutes() {
	const router = express.Router()
	router.get('/add', add)
	router.get('/subtract', subtract)
	return router
}

// all the controller and utility functions here:
async function add(req, res) {
	const sum = Number(req.query.a) + Number(req.query.c)
	res.send(sum.toString())
}

async function subtract(req, res) {
	const difference = Number(req.query.a) - Number(req.query.b)
	res.send(difference.toString())
}

export { getMathRoutes }