How to Structure Your Express.js Applications - Featured Image
Web development8 min read

How to Structure Your Express.js Applications

If you've been working with Node.js and Express for a while, you've probably realized that as your projects grow, keeping everything organized becomes a real challenge. I learned this the hard way when my first big Express project turned into a complete mess of spaghetti code that even I couldn't understand after a few months.

After years of trial and error (and many late-night refactoring sessions), I've finally settled on a folder structure that actually makes sense and keeps my sanity intact. In this post, I'll walk you through what I consider to be a solid structure for Express.js apps and explain why each piece matters.

The structure

Before diving into details, here's what a well-organized Express app structure typically looks like:

šŸ“
ā”œā”€ā”€ šŸ“„ app.js
ā”œā”€ā”€ šŸ“ bin
ā”œā”€ā”€ šŸ“ config
ā”œā”€ā”€ šŸ“ controllers
│   ā”œā”€ā”€ šŸ“„ customer.js
│   ā”œā”€ā”€ šŸ“„ product.js
ā”œā”€ā”€ šŸ“ middleware
│   ā”œā”€ā”€ šŸ“„ auth.js
│   ā”œā”€ā”€ šŸ“„ logger.js
ā”œā”€ā”€ šŸ“ models
│   ā”œā”€ā”€ šŸ“„ customer.js
│   ā”œā”€ā”€ šŸ“„ product.js
ā”œā”€ā”€ šŸ“ routes
│   ā”œā”€ā”€ šŸ“„ api.js
│   ā”œā”€ā”€ šŸ“„ auth.js
ā”œā”€ā”€ šŸ“ public
│   ā”œā”€ā”€ šŸ“ css
│   ā”œā”€ā”€ šŸ“ js
│   ā”œā”€ā”€ šŸ“ images
ā”œā”€ā”€ šŸ“ views
│   ā”œā”€ā”€ šŸ“„ index.ejs
│   ā”œā”€ā”€ šŸ“„ product.ejs
ā”œā”€ā”€ šŸ“ tests
│   ā”œā”€ā”€ šŸ“ unit
│   ā”œā”€ā”€ šŸ“ integration
│   ā”œā”€ā”€ šŸ“ e2e
ā”œā”€ā”€ šŸ“ utils
│   ā”œā”€ā”€ šŸ“„ validation.js
│   ā”œā”€ā”€ šŸ“„ helpers.js
└── šŸ“ node_modules

Now, let's break down what each of these folders and files actually does.

Core files

The most important files that start and set up your Express app. These are like the engine and control panel of your app - without them, nothing else works.

App.js - Your application's starting point

Think of app.js as the heart of your Express application. This is where everything comes together - you set up your Express instance, connect your middleware, hook up your routes, and get the server running.

const express = require('express');
const app = express();
const config = require('./config');
const routes = require('./routes');

// Set up middleware
app.use(express.json());

// Hook up routes
app.use('/api', routes);

// Fire up the server
const PORT = config.port || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

module.exports = app;

I've seen developers overcomplicate this file with too much logic, but it's best to keep it clean and focused on initialization.

Bin - Your server scripts

The bin directory is where you put scripts related to starting your server. The most common file here is www which handles server creation and error handling. This separation helps keep your core application logic (in app.js) distinct from server-specific concerns.

#!/usr/bin/env node

const app = require('../app');
const debug = require('debug')('your-app:server');
const http = require('http');

const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

const server = http.createServer(app);
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

// Helper functions
function normalizePort(val) {
  const port = parseInt(val, 10);
  if (isNaN(port)) return val;
  if (port >= 0) return port;
  return false;
}

function onError(error) {
  if (error.syscall !== 'listen') throw error;
  const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
  
  // Handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

function onListening() {
  const addr = server.address();
  const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
  debug('Listening on ' + bind);
}

Config - Your application settings

The config directory is where all your environment-specific settings live. This includes database connection strings, API keys, and other configuration variables. Keeping these in a separate folder makes it easy to switch between development, staging, and production environments.

module.exports = {
  port: process.env.PORT || 3000,
  db: {
    host: process.env.DB_HOST || 'localhost',
    port: process.env.DB_PORT || 27017,
    name: process.env.DB_NAME || 'mydatabase'
  },
  jwt: {
    secret: process.env.JWT_SECRET || 'your-secret-key',
    expiresIn: '1d'
  }
};

Main workers

The heavy-lifting components that handle your business logic. These folders contain the code that processes requests, manages data, and implements your app's core functionality.

Controllers - Your request handlers

Controllers contain the logic that processes incoming requests and sends back responses. I like to organize controllers by resource or feature - each file handles operations related to a specific part of the application.

const Customer = require('../models/customer');

exports.getAllCustomers = async (req, res) => {
  try {
    const customers = await Customer.find();
    res.json(customers);
  } catch (err) {
    res.status(500).json({ message: "Couldn't fetch customers", error: err.message });
  }
};

exports.getCustomerById = async (req, res) => {
  try {
    const customer = await Customer.findById(req.params.id);
    if (!customer) return res.status(404).json({ message: 'Customer not found' });
    res.json(customer);
  } catch (err) {
    res.status(500).json({ message: "Couldn't fetch customer", error: err.message });
  }
};

Middleware - Your request processors

Middleware functions handle tasks that should happen before (or sometimes after) your main controller logic. Common examples include authentication, logging, and input validation.

// auth.js - JWT authentication middleware
const jwt = require('jsonwebtoken');
const config = require('../config');

module.exports = (req, res, next) => {
  // Get token from header
  const token = req.header('Authorization')?.replace('Bearer ', '');
  
  if (!token) {
    return res.status(401).json({ message: 'No token provided, access denied' });
  }

  try {
    // Verify the token
    const decoded = jwt.verify(token, config.jwt.secret);
    req.user = decoded;
    next();
  } catch (err) {
    res.status(401).json({ message: 'Invalid token, access denied' });
  }
};

Models - Your data structures

Models define the structure of your data and handle database interactions. If you're using MongoDB with Mongoose, this is where your schemas live. For SQL databases with Sequelize, your model definitions go here.

const mongoose = require('mongoose');

const customerSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true
  },
  phone: {
    type: String,
    trim: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Customer', customerSchema);

Routes - Your URL mappings

Routes connect URLs to controller functions. They define what happens when a user visits a specific endpoint. I usually organize routes by resource or API version.

const express = require('express');
const router = express.Router();
const customerController = require('../controllers/customer');
const authMiddleware = require('../middleware/auth');

// Public routes
router.get('/customers', customerController.getAllCustomers);
router.get('/customers/:id', customerController.getCustomerById);

// Protected routes
router.post('/customers', authMiddleware, customerController.createCustomer);
router.put('/customers/:id', authMiddleware, customerController.updateCustomer);
router.delete('/customers/:id', authMiddleware, customerController.deleteCustomer);

module.exports = router;

The frontend stuff

Everything your users see and interact with directly. This includes static files like stylesheets and images, plus the templates that generate your HTML pages.

Public - Your static assets

The public directory contains files that are served directly to the client without any processing - CSS, client-side JavaScript, images, fonts, etc.

Views - Your HTML templates

If you're rendering HTML on the server (using a templating engine like EJS, Pug, or Handlebars), your templates go in the views directory.

<!-- views/index.ejs -->
<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <header>
    <h1><%= title %></h1>
  </header>
  
  <main>
    <% if (customers.length > 0) { %>
      <ul class="customer-list">
        <% customers.forEach(customer => { %>
          <li>
            <h3><%= customer.name %></h3>
            <p><%= customer.email %></p>
          </li>
        <% }); %>
      </ul>
    <% } else { %>
      <p>No customers found.</p>
    <% } %>
  </main>
  
  <script src="/js/main.js"></script>
</body>
</html>

Support files

The behind-the-scenes helpers that keep your app running smoothly. These include testing suites, utility functions, and external dependencies that support your main application code.

Tests - Your test files

All your tests go in the tests directory. I like to organize them into subdirectories:

  • unit for testing individual functions and components

  • integration for testing how different parts work together

  • e2e (end-to-end) for testing complete user flows

Utils - Your helper functions

The utils directory contains reusable utility functions and helpers that don't fit neatly into the other categories.

// utils/validation.js
exports.isValidEmail = (email) => {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return re.test(String(email).toLowerCase());
};

exports.isStrongPassword = (password) => {
  // At least 8 chars, one uppercase, one lowercase, one number, one special char
  const re = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
  return re.test(password);
};

node_modules - Your dependencies

This is where npm (or yarn) installs all the packages your project depends on. You should never modify files in this directory directly - that's what package.json is for.

Real-World Tips

After building dozens of Express apps, I've learned a few things:

  1. Don't overengineer - Start simple and add complexity only when needed. Not every app needs every folder.

  2. Be consistent - Once you choose a structure, stick with it. Consistency makes your codebase easier to navigate.

  3. Use descriptive names - Name your files based on what they do, not what they are. For example, userAuthentication.js is better than auth.js.

  4. Keep related files close - If you have a file that's only used by one controller, consider keeping it in a subdirectory with that controller rather than in a separate utility folder.

  5. Document your decisions - Add a README.md explaining your folder structure for new team members.

Conclusion

A good Express.js application structure isn't about following a rigid template – it's about organizing your code in a way that makes sense for your specific project and team. The structure I've outlined here has worked well for me across many projects, but don't be afraid to adapt it to your needs. Remember, the goal is to create an organization system that helps you build and maintain your application more effectively, not to follow someone else's rules. As your project grows, you'll thank yourself for taking the time to set up a clean, logical structure from the beginning.

hassaankhan789@gmail.com

Frontend Web Developer

Posted by

ā€Œ
ā€Œ
ā€Œ
ā€Œ

Subscribe to our newsletter

Join 2,000+ subscribers

Stay in the loop with everything you need to know.

We care about your data in our privacy policy

Background shadow leftBackground shadow right

Have something to share?

Write on the platform and dummy copy content

Be Part of Something Big

Shifters, a developer-first community platform, is launching soon with all the features. Don't miss out on day one access. Join the waitlist: