Time's up. The sale is over
Time's up. The sale is over

How I structure Express apps

July 13th, 2020 — 8 min read

by JJ Ying
by JJ Ying
No translations available.Add translation

TL;DR–Explore the example repository

This is the typical structure I use for my medium-sized Node backends. For small backends, I'd probably just put everything in one file and I might not bother with build tooling.


Let's start in the package.json. Here are the relevant bits:

	"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"


This is the entry for our server. So when we run node . in this directory, this is the file that will be run.


This indicates to tools we use which version of node we intend the project to run with.


express is a given (there are plenty of alternatives and if you use one of them that's great, you may still be able to get something out of this blog post regardless). For every Express.js app I have, I also use express-async-errors because it allows me to write my middleware using async/await which is basically a necessity for me. Much less error prone because it ensures that any async errors will be propagated to your error handling middleware.

I like loglevel personally, there are lots of other tools for logging, but loglevel is a good start.


I compile all my stuff with Babel. This allows us to use syntax that's not quite supported in our environment yet (mostly just ESModules) as well as handy plugins like babel-plugin-macros. Hence all the @babel packages:

  • @babel/core is the core babel dependency. Everything else needs it.
  • @babel/cli is for the build script to compile our source code to the output code that Node can run.
  • @babel/preset-env makes it really easy to include all the typical language plugins and transforms we'll need for the environment we're building for.
  • @babel/register is used during development.

If you're using TypeScript, then you may also want to add @babel/preset-typescript.

I also use nodemon for watch mode (restarts the server when files are changed).


The start script simply runs node . which will run the main file (which we have set to index.js).

The build script takes all of the files in src directory (short for "source") and compiles them with babel to the dist directory (short for distribution). Here's an explanation for all the options:

  • --delete-dir-on-start ensures that we don't have old files hanging around between builds
  • --out-dir dist indicates where we want the compiled version of the files to be saved
  • --copy-files indicates that files that are not compiled should be copied instead (useful for .json files for example)
  • --ignore \"**/__tests__/**,**/__mocks__/**\" is necessary so we don't bother compiling any test-related files because we don't need those in production anyway
  • --no-copy-ignored since we're not compiling the ignored files, we want to indicate that we'd also like to not bother copying them either (so this disables --copy-files for the ignored files).

If you're using TypeScript, make sure to add --extensions ".ts,.tsx,.js" to the build script.


Here's what the .babelrc.js looks like:

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

module.exports = {
	presets: [
				targets: {
					node: pkg.engines.node,

It's pretty simple. We compile all the code down to the version of JavaScript syntax that supported by the engines.node value specified in our package.json.

If we were using TypeScript (recommended for teams), then we'd also include @babel/preset-typescript as well.


Here's our entry file for the module (this is the main from package.json):

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

When we run our app in production, it's running on a server which has been configured to set the NODE_ENV environment variable to 'production'. So with our index.js set up the way it is, in production, it will start the server with the compiled version of our code.

However, when running the project locally, instead we'll require nodemon and pass it the options {script: 'dev.js'} which will tell nodemon to run the dev.js script, and re-run it when we make changes. This will improve our feedback loop as we make changes to the server. There are a lot more options for nodemon, and someone mentioned to me that node-dev is another good project to look into so you might give that a look as well.


This one's pretty simple:


The @babel/register sets up babel to compile our files "on the fly" meaning as they're required, Babel will first compile the file before Node gets a chance to run it. Then the require('./src') will require our src/index.js file which is where things really start happening.


This file is pretty simple:

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



All it does is configure the logger and starts the server. Most projects I've seen actually kick off the server in the src/index.js file, but I prefer to take the logic for starting the server and put it in a function because it makes it easier for testing.


Ok, here's where things really start getting "expressy". For this one, I'll explain things in code comments.

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

	// 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) => {

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

			// resolve the whole promise with the express 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) {
	} else {
			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
			.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 }

Doing things this way makes it easier to test. For example, an integration test could simply do 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

If this sounds interesting to you, then let me teach you on TestingJavaScript.com 🏆


This is where all the routes for my app come together:

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 }


This is just a contrived example of some routes/middleware/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)

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

export { getMathRoutes }


And that's it. Hopefully that's interesting and useful! If you want to learn about the testing side of this stuff, don't miss the Test Node.js Backends module on TestingJavaScript.com.

Testing JavaScript

Ship Apps with Confidence

Illustration of a trophy
Kent C. Dodds
Written by Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. Kent's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Learn more about Kent

If you found this article helpful.

You will love these ones as well.