This site runs best with JavaScript enabled.

How I structure Express apps

Photo by JJ Ying

The build/dev tools and scripts I use for Node backends.

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:

2 "main": "index.js",
3 "engines": {
4 "node": "12.18.2"
5 },
6 "dependencies": {
7 "express": "^4.17.1",
8 "express-async-errors": "^3.1.1",
9 "loglevel": "^1.6.8"
10 },
11 "devDependencies": {
12 "@babel/cli": "^7.10.4",
13 "@babel/core": "^7.10.4",
14 "@babel/preset-env": "^7.10.4",
15 "@babel/register": "^7.10.4",
16 "nodemon": "^2.0.4"
17 },
18 "scripts": {
19 "start": "node .",
20 "build": "babel --delete-dir-on-start --out-dir dist --copy-files --ignore \"**/__tests__/**,**/__mocks__/**\" --no-copy-ignored src"
21 }


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:

1const pkg = require('./package.json')
3module.exports = {
4 presets: [
5 [
6 '@babel/preset-env',
7 {
8 targets: {
9 node: pkg.engines.node,
10 },
11 },
12 ],
13 ],

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):

1if (process.env.NODE_ENV === 'production') {
2 require('./dist')
3} else {
4 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:

1import logger from 'loglevel'
2import {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.

1import express from 'express'
3// this is all it takes to enable async/await for express middleware
4import 'express-async-errors'
6import logger from 'loglevel'
8// all the routes for my app are retrieved from the src/routes/index.js module
9import {getRoutes} from './routes'
11function startServer({port = process.env.PORT} = {}) {
12 const app = express()
14 // I mount my entire app to the /api route (or you could just do "/" if you want)
15 app.use('/api', getRoutes())
17 // add the generic error handler just in case errors are missed by middleware
18 app.use(errorMiddleware)
20 // I prefer dealing with promises. It makes testing easier, among other things.
21 // So this block of code allows me to start the express app and resolve the
22 // promise with the express server
23 return new Promise(resolve => {
24 const server = app.listen(port, () => {
25`Listening on port ${server.address().port}`)
27 // this block of code turns `server.close` into a promise API
28 const originalClose = server.close.bind(server)
29 server.close = () => {
30 return new Promise(resolveClose => {
31 originalClose(resolveClose)
32 })
33 }
35 // this ensures that we properly close the server when the program exists
36 setupCloseOnExit(server)
38 // resolve the whole promise with the express server
39 resolve(server)
40 })
41 })
44// here's our generic error handler for situations where we didn't handle
45// errors properly
46function errorMiddleware(error, req, res, next) {
47 if (res.headersSent) {
48 next(error)
49 } else {
50 logger.error(error)
51 res.status(500)
52 res.json({
53 message: error.message,
54 // we only add a `stack` property in non-production environments
55 ...(process.env.NODE_ENV === 'production' ? null : {stack: error.stack}),
56 })
57 }
60// ensures we close the server in the event of an error.
61function setupCloseOnExit(server) {
62 // thank you stack overflow
63 //
64 async function exitHandler(options = {}) {
65 await server
66 .close()
67 .then(() => {
68'Server successfully closed')
69 })
70 .catch(e => {
71 logger.warn('Something went wrong closing the server', e.stack)
72 })
74 if (options.exit) process.exit()
75 }
77 // do something when app is closing
78 process.on('exit', exitHandler)
80 // catches ctrl+c event
81 process.on('SIGINT', exitHandler.bind(null, {exit: true}))
83 // catches "kill pid" (for example: nodemon restart)
84 process.on('SIGUSR1', exitHandler.bind(null, {exit: true}))
85 process.on('SIGUSR2', exitHandler.bind(null, {exit: true}))
87 // catches uncaught exceptions
88 process.on('uncaughtException', exitHandler.bind(null, {exit: true}))
91export {startServer}

Doing things this way makes it easier to test. For example, an integration test could simply do this:

1import {startServer} from '../start'
3let server, baseURL
4beforeAll(async () => {
5 server = await startServer()
6 baseURL = `http://localhost:${server.address().port}/api`
9afterAll(() => server.close())
11// make requests to the baseURL

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


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

1import express from 'express'
2// any other routes imports would go here
3import {getMathRoutes} from './math'
5function getRoutes() {
6 // create a router for all the routes of our app
7 const router = express.Router()
9 router.use('/math', getMathRoutes())
10 // any additional routes would go here
12 return router
15export {getRoutes}


This is just a contrived example of some routes/middleware/express controllers.

1import express from 'express'
3// A function to get the routes.
4// That way all the route definitions are in one place which I like.
5// This is the only thing that's exported
6function getMathRoutes() {
7 const router = express.Router()
8 router.get('/add', add)
9 router.get('/subtract', subtract)
10 return router
13// all the controller and utility functions here:
14async function add(req, res) {
15 const sum = Number(req.query.a) + Number(req.query.c)
16 res.send(sum.toString())
19async function subtract(req, res) {
20 const difference = Number(req.query.a) - Number(req.query.b)
21 res.send(difference.toString())
24export {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

Discuss on TwitterEdit post on GitHub

Share article

Your Essential Guide to Flawless Testing

Start Now

Write well tested JavaScript.

Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He'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.

Join the Newsletter

Kent C. Dodds