How to structure an Express.js REST API – best practices

So you’ve decided to join thousands of other companies and use Express.js to build a REST API. How should you structure your project?

One of the great things about Express is that it’s a very light and unopinionated framework which makes it extremely flexible in how you set it up.

A possible downside is that there isn’t much guidance on how to structure applications. This can lead to issues around maintenance and scaling.

In this post, we’ll consider some best practice approaches to structure a robust and high-performing Express REST API project.

Why is structure important?

Why does structure matter? Why not use a “move fast and break things” approach and put your code wherever it seems to work?

Without good structure, it will become increasingly hard to understand your code and easier to accidentally break things. This means bugs increase and deployments become slower and riskier.

If you plan to scale your API the decisions you bake in earlier should support that. Otherwise, you’ll need to spend considerable time and expense rewriting your application.

Important concepts

While there are a variety of recommendations below, they are all based on the same principles:

  • Separation of concerns. Organizing functions and modules to ensure they have a single, clear task which ensures your code is easy to read and maintain.
  • Modular architecture. Composing your app in pieces that are isolated and easy to understand. This ensures your code is flexible and allows it to be recomposed for cron tasks, unit testing, etc.

Keeping these principles in mind means you’ll know what to do when your requirement go beyond those presented here and in other examples.

Separate app and server

The first thing you’ll typically do in an Express API project is to configure a basic app and assign it to an HTTP server. This is commonly, but incorrectly, done in a single file:

const app = express()
app.use(bodyParser.json())
app.use('/api/products', products)
app.use('/api/orders', orders)
const port = process.env.PORT || '3000'
app.listen(port)

index.js

Since there are two entities here, the app and the server, they should be separated. Not just on principle, but also because separating the app and server also allows you to unit test your app without initializing the server.

So let’s refactor this. We’ll have an app folder where we’ll keep the application code and create an index.js  module inside where we configure the app.

const express = require('express')
const app = express()
app.use(bodyParser.json())
app.use('/api/products', products)
app.use('/api/orders', orders)
module.exports = app

app/index.js

We’ll put the server.js file in the project root. This file will import the app and assign an HTTP server. It will also serve as the entry point of our app.

const app = require('./app')
const port = process.env.PORT || '3000'
app.listen(port)

server.js

We could also add additional network-related configuration in this file vhosts, SSL, etc.

Three-layer app architecture

To structure our API app we’ll use the popular “three-layer architecture”.

1. Web layer. Responsible for sending, receiving, and validating HTTP requests. Common configuration here includes routes, controllers, and middleware.

2. Service layer. Contains business logic.

3. Data access layer. Where we read and write to a database. We typically use an ORM like Mongoose or Sequelize.

This architecture creates a good separation of concerns that will make it easy to read and maintain the code.

It’s also modular enough to allow for recomposition. For example, you may want to create interfaces for cron tasks or CLI commands. These can seamlessly replace the web layer.

Let’s now look in more detail at these layers and their contents.

Web layer

The web layer is where we process HTTP requests, dispatch data to the service layer, then return an HTTP response.

Anything dealing with the req and res objects lives here. The main abstractions we utilize in this layer are:

  • Routes. Where you declare the path of API endpoints and assign to controllers.

  • Middleware. Reusable plugins to modify requests typically used for cache control, authentication, error handling, etc.

  • Controllers. The methods that process an endpoint and unpack web layer data to dispatch to services.

Here’s an example file structure with product and user example entities.

app
  index.js
  routes
     products.js
     users.js
  controllers
     products.js
     users.js
  middleware
    cacheNoStore.js
server.js

Routes

In simple projects you will often see the routes and logic declared together:

app.use('/api/products/:id', (req, res) => {
// logic here
})

// app/index.js

A best practice is to abstract routes into a module that has the job of mapping paths to controller methods.

It’s also a good idea to have a separate routes file for each API entity e.g. products, users, etc.

Here’s an example routes file:

const controllers = require('./controller.js')
const router = require('express').Router()

module.exports = () => {
   router.get('/', controllers.allProducts)
   router.get('/:id', controllers.getProduct)
   router.post('/', controllers.createProduct)
}

app/routes/products.js

If you have reusable functionality like cache control, access checks, or input validation, it’s a good idea to abstract these into middleware plugins.

These can then be applied on a per-route basis in the routes file:

const cacheNoStore = require('./middleware/cacheNoStore.js')
module.exports = () => {
   router.get('/:id', cacheNoStore, controllers.getProduct)
}

app/routes/products.js

With your route modules created, you can declare them in your app index file. Note that a path prefix can also be set here.

app.use('/api/products', require('./routes/products'))
app.use('/api/orders', require('./routes/orders'))

app/index.js

💡

A great resource for creating REST APIs is the article The 10 REST Commandments

Controllers

The best practice for controllers is to keep them free of business logic. Their single job is to get any relevant data from the req object and dispatch it to services. The returned value should be ready to be sent in a response using res.

Make sure you don’t pass any web layer objects (i.e. req, res, headers, etc) into your services. Instead, unwrap any values like URL parameters, header values, body data, etc before dispatching to the service layer.

Here’s an example controller module.

const { getProduct } = require('../services/products')

module.exports = () => {
	getProduct: async (req, res) => {
		try {
			const id = req.params.id
			const product = await getProduct(id)
			res.json(product)
		}
		catch (err) {
		res.status(500).send(err)
		}
	}
}

app/controllers/product.js

Data access layer

The final layer to consider in our app structure is the data access layer. This is the layer that communicates with your database.

Most modern applications will use an ORM like Mongoose or Sequelize. The models you create for these will serve as your data access layer.

app
	index.js
	routes
		products.js
		users.js
	controllers
		products.js
		users.js
	middleware
		cacheNoStore.js
	models
		product.js
		user.js
	services
		products.js
		users.js
server.js

Separation into components

In some scenarios, you may consider a further degree of separation: components.

As explained on the Node Best Practices site, if you have logically independent API features (e.g. an admin API and a user API) it’s a good sign they should be separated into components.

To implement a component structure, the app folder we considered above would be refactored and duplicated to be named based on each entity.

Note that we’ve provided common folders for middleware and models as these will be likely shared utilities.

products
	index.js
	routes.js
	controllers.js
	services.js
users
	index.js
	routes.js
	controllers.js
	services.js
middleware
	cacheNoStore.js
models
	product.js
	user.js
server.js

Component advantages

💡

If you’re looking to build and scale a world-class API with Express, you should also consider Treblle.

Treblle is API observability software that can be installed in an Express app with just a few lines of code. It will monitor and trace any issues with your API so that they can be caught and fixed before affecting your uses.

In fact, the Treblle JS SDK also supports Node, Express, KoaJS, Strapi, and Cloudflare Workers!

In addition to API observation, Treblle offers your organization a variety of other useful features including:

  • Auto-generated API documentation

  • API quality scores

  • 1 click testing

And more. Treblle is free for up 30,000 requests and can be installed in minutes, so give it a try!

Wrap up

Express is one of the most popular Node.js frameworks, appreciated for its speed and flexibility. If you aren’t careful, though, it’s easy to create an untamed codebase that becomes a hassle to maintain and scale.

By practicing the principles of separation of concern and of modular architecture, we can ensure our Express APIs are robust and flexible.