Why Express?

Express is the most widely used Node.js web framework for good reason: it's minimal, flexible, and gets out of your way. It doesn't enforce a specific project structure or architecture, which means you make the decisions — and understanding those decisions is what this guide is about.

Project Setup

Start with a clean Node.js project:

mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev nodemon

Add a start script to package.json:

"scripts": {
  "dev": "nodemon src/index.js"
}

Basic Server Structure

A well-organized Express project separates concerns from the start:

src/
  index.js         ← Entry point
  app.js           ← Express app config
  routes/
    users.js       ← Route definitions
  controllers/
    userController.js
  middleware/
    errorHandler.js

app.js

const express = require('express');
const app = express();

app.use(express.json()); // Parse JSON bodies

const userRoutes = require('./routes/users');
app.use('/api/users', userRoutes);

module.exports = app;

index.js

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Defining Routes

Keep routes thin — they should delegate logic to controllers:

// routes/users.js
const express = require('express');
const router = express.Router();
const { getUsers, createUser } = require('../controllers/userController');

router.get('/', getUsers);
router.post('/', createUser);

module.exports = router;

Writing Controllers

// controllers/userController.js
const users = []; // Replace with DB call in production

exports.getUsers = (req, res) => {
  res.status(200).json({ success: true, data: users });
};

exports.createUser = (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ success: false, message: 'Name and email required' });
  }
  const newUser = { id: Date.now(), name, email };
  users.push(newUser);
  res.status(201).json({ success: true, data: newUser });
};

Centralized Error Handling

Avoid scattered try/catch blocks by using Express's built-in error-handling middleware (four-parameter form):

// middleware/errorHandler.js
module.exports = (err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({
    success: false,
    message: err.message || 'Internal Server Error',
  });
};

Register it after all routes in app.js:

const errorHandler = require('./middleware/errorHandler');
app.use(errorHandler);

REST API Design Principles to Follow

  • Use nouns for resources, not verbs: /users, not /getUsers
  • Use HTTP methods correctly: GET = read, POST = create, PUT/PATCH = update, DELETE = remove
  • Return consistent response shapes: always include a success flag and a data or message field
  • Use appropriate HTTP status codes: 200, 201, 400, 401, 404, 500
  • Validate input early and return clear error messages

Next Steps

Once your basic API is working, the natural progression is:

  1. Connect a real database (MongoDB with Mongoose, or PostgreSQL with Prisma)
  2. Add authentication (JWT or sessions)
  3. Implement request validation with a library like Zod or Joi
  4. Add rate limiting and helmet for basic security
  5. Write integration tests with Supertest

A clean, well-structured Express API isn't just easier to build — it's dramatically easier to maintain, debug, and hand off to teammates.