Node.js Express API: Complete Guide to Building RESTful APIs

February 12, 2026 · 30 min read

Express.js is the most widely used web framework in the Node.js ecosystem, powering everything from simple REST APIs to complex microservice architectures. Its minimalist design, powerful middleware system, and massive community make it the go-to choice for backend JavaScript developers. Whether you are building a startup MVP, a production API serving millions of requests, or a microservice in a larger system, Express provides exactly the right amount of structure without imposing unnecessary opinions.

This guide walks through every aspect of building production-ready APIs with Node.js and Express. We start from project setup and progress through routing, middleware, CRUD operations, authentication, database integration, error handling, testing, security, and deployment. Every section includes working code that you can adapt directly into your projects.

⚙ Related tools: Format API responses with the JSON Formatter, test API payloads with the Base64 Encoder, and keep our JavaScript Cheat Sheet open as a quick reference.

Table of Contents

  1. Setting Up a Node.js Project
  2. Express.js Fundamentals
  3. REST API Design Principles
  4. CRUD Operations with Express
  5. Middleware Deep Dive
  6. Request Validation
  7. Authentication with JWT
  8. Database Integration
  9. Error Handling Patterns
  10. API Versioning
  11. Rate Limiting and Security
  12. Testing with Jest and Supertest
  13. Project Structure Best Practices
  14. Deployment Considerations
  15. Frequently Asked Questions

1. Setting Up a Node.js Project

Every Express project starts with initializing a Node.js project and installing dependencies. Here is the complete setup from an empty directory to a running server.

# Create project directory and initialize
mkdir my-api && cd my-api
npm init -y

# Install core dependencies
npm install express dotenv cors

# Install development dependencies
npm install -D nodemon jest supertest

# Create project structure
mkdir -p src/{routes,controllers,middleware,models,services,utils,config}
touch src/app.js src/server.js .env .env.example

Configure your package.json scripts for development and production:

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest --verbose --forceExit",
    "test:watch": "jest --watch"
  }
}

Set up environment variables in .env:

# .env
NODE_ENV=development
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRES_IN=7d

Create the Express application in src/app.js and the server entry point in src/server.js separately. This separation is critical — it allows you to import the app in tests without starting the HTTP server.

// src/app.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();

const app = express();

// Core middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

module.exports = app;
// src/server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;

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

2. Express.js Fundamentals

Express revolves around three core concepts: routing (matching URLs to handler functions), middleware (functions that process requests before they reach route handlers), and the request/response cycle.

Routing

Routes map HTTP methods and URL patterns to handler functions. Express supports all HTTP methods and flexible URL patterns including parameters, wildcards, and regex.

const express = require('express');
const router = express.Router();

// Basic routes
router.get('/users', listUsers);           // GET /users
router.post('/users', createUser);         // POST /users
router.get('/users/:id', getUser);         // GET /users/123
router.put('/users/:id', updateUser);      // PUT /users/123
router.patch('/users/:id', patchUser);     // PATCH /users/123
router.delete('/users/:id', deleteUser);   // DELETE /users/123

// Route parameters are available on req.params
router.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

// Query parameters are on req.query
// GET /search?q=express&page=2&limit=20
router.get('/search', (req, res) => {
  const { q, page = 1, limit = 10 } = req.query;
  res.json({ query: q, page: Number(page), limit: Number(limit) });
});

// Route chaining for the same path
router.route('/articles')
  .get(listArticles)
  .post(authenticate, createArticle);

router.route('/articles/:id')
  .get(getArticle)
  .put(authenticate, updateArticle)
  .delete(authenticate, authorize('admin'), deleteArticle);

Request and Response Objects

router.post('/users', (req, res) => {
  // Request properties
  console.log(req.body);          // parsed JSON body
  console.log(req.params);        // URL parameters
  console.log(req.query);         // query string
  console.log(req.headers);       // request headers
  console.log(req.method);        // 'POST'
  console.log(req.path);          // '/users'
  console.log(req.ip);            // client IP address
  console.log(req.get('Content-Type')); // specific header

  // Response methods
  res.status(201).json({ id: 1, name: 'Alice' }); // JSON response
  // res.status(204).send();           // No content
  // res.status(301).redirect('/new'); // Redirect
  // res.status(404).json({ error: 'Not found' });
});

3. REST API Design Principles

A well-designed REST API follows consistent conventions that make it predictable and easy to consume. These principles are not Express-specific, but Express makes it straightforward to implement them.

// Consistent response format
// Success:
{
  "status": "success",
  "data": { "user": { "id": 1, "name": "Alice" } }
}

// List with pagination:
{
  "status": "success",
  "results": 20,
  "data": { "users": [...] },
  "pagination": { "page": 2, "limit": 20, "total": 156 }
}

// Error:
{
  "status": "error",
  "message": "User not found",
  "code": "USER_NOT_FOUND"
}

4. CRUD Operations with Express

Here is a complete CRUD implementation for a users resource. This pattern applies to virtually any resource in your API.

// src/controllers/userController.js
const User = require('../models/User');

// GET /api/users
exports.getUsers = async (req, res, next) => {
  try {
    const { page = 1, limit = 10, sort = '-createdAt', role } = req.query;

    const filter = {};
    if (role) filter.role = role;

    const users = await User.find(filter)
      .sort(sort)
      .skip((page - 1) * limit)
      .limit(Number(limit))
      .select('-password');

    const total = await User.countDocuments(filter);

    res.json({
      status: 'success',
      results: users.length,
      data: { users },
      pagination: {
        page: Number(page),
        limit: Number(limit),
        total,
        pages: Math.ceil(total / limit)
      }
    });
  } catch (err) {
    next(err);
  }
};

// GET /api/users/:id
exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id).select('-password');
    if (!user) {
      return res.status(404).json({
        status: 'error',
        message: 'User not found'
      });
    }
    res.json({ status: 'success', data: { user } });
  } catch (err) {
    next(err);
  }
};

// POST /api/users
exports.createUser = async (req, res, next) => {
  try {
    const { name, email, password, role } = req.body;
    const user = await User.create({ name, email, password, role });

    // Remove password from response
    const userObj = user.toObject();
    delete userObj.password;

    res.status(201).json({ status: 'success', data: { user: userObj } });
  } catch (err) {
    next(err);
  }
};

// PUT /api/users/:id
exports.updateUser = async (req, res, next) => {
  try {
    const { name, email, role } = req.body;
    const user = await User.findByIdAndUpdate(
      req.params.id,
      { name, email, role },
      { new: true, runValidators: true }
    ).select('-password');

    if (!user) {
      return res.status(404).json({
        status: 'error',
        message: 'User not found'
      });
    }
    res.json({ status: 'success', data: { user } });
  } catch (err) {
    next(err);
  }
};

// DELETE /api/users/:id
exports.deleteUser = async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) {
      return res.status(404).json({
        status: 'error',
        message: 'User not found'
      });
    }
    res.status(204).send();
  } catch (err) {
    next(err);
  }
};

Wire the controller to routes:

// src/routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { authenticate, authorize } = require('../middleware/auth');
const { validateUser } = require('../middleware/validate');

router.route('/')
  .get(userController.getUsers)
  .post(authenticate, validateUser, userController.createUser);

router.route('/:id')
  .get(userController.getUser)
  .put(authenticate, validateUser, userController.updateUser)
  .delete(authenticate, authorize('admin'), userController.deleteUser);

module.exports = router;

5. Middleware Deep Dive

Middleware functions are the backbone of Express. They have access to the request object, the response object, and the next middleware function. They can execute code, modify req/res, end the request-response cycle, or call the next middleware.

Built-in Middleware

// Parse JSON bodies
app.use(express.json({ limit: '10mb' }));

// Parse URL-encoded bodies (form submissions)
app.use(express.urlencoded({ extended: true }));

// Serve static files
app.use(express.static('public'));

Custom Middleware

// Request logging middleware
const requestLogger = (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
  });
  next();
};

// Request ID middleware (for tracing)
const { randomUUID } = require('crypto');
const requestId = (req, res, next) => {
  req.id = req.headers['x-request-id'] || randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
};

// Apply globally
app.use(requestId);
app.use(requestLogger);

// Apply to specific routes only
app.use('/api/admin', authenticate, authorize('admin'));

Async Handler Wrapper

Writing try-catch in every async route handler is repetitive. A wrapper function eliminates this boilerplate:

// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;

// Usage in controllers - no more try-catch
const asyncHandler = require('../utils/asyncHandler');

exports.getUsers = asyncHandler(async (req, res) => {
  const users = await User.find().select('-password');
  res.json({ status: 'success', data: { users } });
  // Errors automatically forwarded to error handler
});

6. Request Validation

Never trust client input. Validate every request body, parameter, and query string before processing. The two most popular options are express-validator (lightweight, Express-specific) and Joi (schema-based, framework-agnostic).

With express-validator

npm install express-validator
const { body, param, query, validationResult } = require('express-validator');

// Reusable validation error handler
const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json({
      status: 'error',
      message: 'Validation failed',
      errors: errors.array().map(e => ({
        field: e.path,
        message: e.msg
      }))
    });
  }
  next();
};

// Validation rules for user creation
const validateCreateUser = [
  body('name')
    .trim()
    .notEmpty().withMessage('Name is required')
    .isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
  body('email')
    .trim()
    .notEmpty().withMessage('Email is required')
    .isEmail().withMessage('Must be a valid email')
    .normalizeEmail(),
  body('password')
    .notEmpty().withMessage('Password is required')
    .isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
    .matches(/\d/).withMessage('Password must contain a number')
    .matches(/[A-Z]/).withMessage('Password must contain an uppercase letter'),
  body('role')
    .optional()
    .isIn(['user', 'admin']).withMessage('Role must be user or admin'),
  validate  // Run validation check after all rules
];

// Apply to route
router.post('/users', validateCreateUser, userController.createUser);

With Joi

npm install joi
const Joi = require('joi');

const userSchema = Joi.object({
  name: Joi.string().trim().min(2).max(50).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8)
    .pattern(/[A-Z]/, 'uppercase letter')
    .pattern(/\d/, 'number')
    .required(),
  role: Joi.string().valid('user', 'admin').default('user')
});

// Generic Joi validation middleware
const validateBody = (schema) => (req, res, next) => {
  const { error, value } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(422).json({
      status: 'error',
      message: 'Validation failed',
      errors: error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }))
    });
  }
  req.body = value; // Use sanitized values
  next();
};

router.post('/users', validateBody(userSchema), userController.createUser);

7. Authentication with JWT

JSON Web Tokens (JWT) are the standard for stateless API authentication. The server issues a signed token upon login, and the client sends it with every subsequent request in the Authorization header.

npm install jsonwebtoken bcrypt
// src/controllers/authController.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');

const signToken = (userId) => {
  return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRES_IN || '7d'
  });
};

// POST /api/auth/register
exports.register = asyncHandler(async (req, res) => {
  const { name, email, password } = req.body;

  // Check if user exists
  const existing = await User.findOne({ email });
  if (existing) {
    return res.status(409).json({
      status: 'error',
      message: 'Email already registered'
    });
  }

  // Hash password and create user
  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await User.create({ name, email, password: hashedPassword });

  const token = signToken(user._id);
  res.status(201).json({
    status: 'success',
    data: { token, user: { id: user._id, name, email } }
  });
});

// POST /api/auth/login
exports.login = asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email }).select('+password');
  if (!user || !(await bcrypt.compare(password, user.password))) {
    return res.status(401).json({
      status: 'error',
      message: 'Invalid email or password'
    });
  }

  const token = signToken(user._id);
  res.json({
    status: 'success',
    data: { token, user: { id: user._id, name: user.name, email } }
  });
});
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

exports.authenticate = async (req, res, next) => {
  try {
    // Extract token from Authorization header
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({
        status: 'error',
        message: 'Authentication required'
      });
    }

    const token = authHeader.split(' ')[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Verify user still exists
    const user = await User.findById(decoded.id).select('-password');
    if (!user) {
      return res.status(401).json({
        status: 'error',
        message: 'User no longer exists'
      });
    }

    req.user = user;
    next();
  } catch (err) {
    return res.status(401).json({
      status: 'error',
      message: 'Invalid or expired token'
    });
  }
};

// Role-based authorization
exports.authorize = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) {
    return res.status(403).json({
      status: 'error',
      message: 'You do not have permission to perform this action'
    });
  }
  next();
};

8. Database Integration

MongoDB with Mongoose

npm install mongoose
// src/config/database.js
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.DATABASE_URL, {
      maxPoolSize: 10
    });
    console.log(`MongoDB connected: ${conn.connection.host}`);
  } catch (err) {
    console.error('Database connection error:', err.message);
    process.exit(1);
  }
};

module.exports = connectDB;
// src/models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    minlength: [2, 'Name must be at least 2 characters']
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email']
  },
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: 8,
    select: false  // Excluded from queries by default
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  }
}, {
  timestamps: true,  // Adds createdAt and updatedAt
  toJSON: { virtuals: true },
  toObject: { virtuals: true }
});

// Index for common queries
userSchema.index({ email: 1 });
userSchema.index({ role: 1, createdAt: -1 });

// Virtual field
userSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'author'
});

module.exports = mongoose.model('User', userSchema);

PostgreSQL with Knex

npm install knex pg
// knexfile.js
module.exports = {
  development: {
    client: 'pg',
    connection: process.env.DATABASE_URL,
    migrations: { directory: './src/db/migrations' },
    seeds: { directory: './src/db/seeds' }
  },
  production: {
    client: 'pg',
    connection: { connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } },
    pool: { min: 2, max: 10 },
    migrations: { directory: './src/db/migrations' }
  }
};

// Create a migration:  npx knex migrate:make create_users
// src/db/migrations/20260212_create_users.js
exports.up = function(knex) {
  return knex.schema.createTable('users', (table) => {
    table.increments('id').primary();
    table.string('name', 50).notNullable();
    table.string('email', 255).notNullable().unique();
    table.string('password', 255).notNullable();
    table.enum('role', ['user', 'admin']).defaultTo('user');
    table.timestamps(true, true);  // created_at, updated_at
    table.index(['email']);
    table.index(['role', 'created_at']);
  });
};

exports.down = function(knex) {
  return knex.schema.dropTable('users');
};

// Query with Knex
const knex = require('../config/database');

// SELECT with pagination
const users = await knex('users')
  .select('id', 'name', 'email', 'role', 'created_at')
  .where('role', 'admin')
  .orderBy('created_at', 'desc')
  .limit(20)
  .offset(0);

// INSERT
const [user] = await knex('users')
  .insert({ name, email, password: hashedPassword })
  .returning('*');

// UPDATE
await knex('users').where('id', id).update({ name, email });

// DELETE
await knex('users').where('id', id).del();

9. Error Handling Patterns

Centralized error handling keeps your controllers clean and ensures every error is caught and returned in a consistent format. This is one of the most important architectural decisions in an Express API.

// src/utils/AppError.js - Custom error class
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code || 'ERROR';
    this.isOperational = true; // Distinguishes expected errors from bugs
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

// Usage in controllers:
const AppError = require('../utils/AppError');

exports.getUser = asyncHandler(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new AppError('User not found', 404, 'USER_NOT_FOUND');
  }
  res.json({ status: 'success', data: { user } });
});
// src/middleware/errorHandler.js - Global error handler
const errorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;

  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    err.statusCode = 409;
    err.message = `Duplicate value for ${field}`;
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    err.statusCode = 422;
    err.message = Object.values(err.errors).map(e => e.message).join(', ');
  }

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    err.statusCode = 400;
    err.message = `Invalid ${err.path}: ${err.value}`;
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    err.statusCode = 401;
    err.message = 'Invalid token';
  }
  if (err.name === 'TokenExpiredError') {
    err.statusCode = 401;
    err.message = 'Token expired';
  }

  // Send response
  const response = {
    status: 'error',
    message: err.message
  };

  // Include stack trace in development only
  if (process.env.NODE_ENV === 'development') {
    response.stack = err.stack;
  }

  console.error(`[ERROR] ${req.method} ${req.originalUrl}:`, err.message);
  res.status(err.statusCode).json(response);
};

module.exports = errorHandler;

// Register it LAST in app.js (after all routes)
app.use(errorHandler);

Always add a 404 handler for unmatched routes before the error handler:

// In app.js, after all route registrations
app.all('*', (req, res) => {
  res.status(404).json({
    status: 'error',
    message: `Route ${req.originalUrl} not found`
  });
});

app.use(errorHandler);

10. API Versioning

API versioning allows you to evolve your API without breaking existing clients. The most common approach is URL prefix versioning because it is explicit and easy to implement.

// src/app.js - URL prefix versioning
const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./userRoutes'));
router.use('/posts', require('./postRoutes'));
router.use('/auth', require('./authRoutes'));
module.exports = router;

// src/routes/v2/index.js - v2 can import v1 handlers and override only what changed
const router = require('express').Router();
const v1UserController = require('../../controllers/v1/userController');
const v2UserController = require('../../controllers/v2/userController');

// v2 uses new getUsers but keeps v1 createUser
router.get('/users', v2UserController.getUsers);
router.post('/users', v1UserController.createUser);
module.exports = router;

Alternative approaches include header versioning (Accept: application/vnd.myapp.v2+json) and query parameter versioning (/users?version=2). URL prefix is recommended because it is the most transparent and cacheable.

11. Rate Limiting and Security

Security is not optional. Every production API needs these protections at minimum.

npm install helmet express-rate-limit express-mongo-sanitize hpp
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');

// Security HTTP headers
app.use(helmet());

// Rate limiting - global
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    status: 'error',
    message: 'Too many requests, please try again later'
  }
});
app.use('/api', globalLimiter);

// Stricter rate limit for authentication routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,  // Only 10 login attempts per 15 minutes
  message: {
    status: 'error',
    message: 'Too many login attempts, please try again in 15 minutes'
  }
});
app.use('/api/auth', authLimiter);

// Prevent NoSQL injection
app.use(mongoSanitize());

// Prevent HTTP parameter pollution
app.use(hpp({
  whitelist: ['sort', 'fields', 'page', 'limit']
}));

// CORS configuration for specific origins
const cors = require('cors');
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? ['https://myapp.com', 'https://admin.myapp.com']
    : '*',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

12. Testing with Jest and Supertest

Testing ensures your API works correctly and stays working as you make changes. Use Jest as the test runner and Supertest for making HTTP requests against your Express app without starting a real server.

npm install -D jest supertest
// tests/users.test.js
const request = require('supertest');
const app = require('../src/app');
const mongoose = require('mongoose');
const User = require('../src/models/User');

let authToken;

beforeAll(async () => {
  await mongoose.connect(process.env.TEST_DATABASE_URL);
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await User.deleteMany({});

  // Create a test user and get auth token
  const res = await request(app)
    .post('/api/auth/register')
    .send({ name: 'Test User', email: 'test@example.com', password: 'Password123' });

  authToken = res.body.data.token;
});

describe('GET /api/users', () => {
  it('should return a list of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200);

    expect(res.body.status).toBe('success');
    expect(Array.isArray(res.body.data.users)).toBe(true);
    expect(res.body.data.users[0]).not.toHaveProperty('password');
  });

  it('should support pagination', async () => {
    const res = await request(app)
      .get('/api/users?page=1&limit=5')
      .expect(200);

    expect(res.body.pagination).toHaveProperty('page', 1);
    expect(res.body.pagination).toHaveProperty('limit', 5);
    expect(res.body.pagination).toHaveProperty('total');
  });
});

describe('POST /api/users', () => {
  it('should create a user with valid data', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ name: 'Alice', email: 'alice@example.com', password: 'Secure123' })
      .expect(201);

    expect(res.body.data.user).toHaveProperty('name', 'Alice');
    expect(res.body.data.user).toHaveProperty('email', 'alice@example.com');
    expect(res.body.data.user).not.toHaveProperty('password');
  });

  it('should reject invalid email', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ name: 'Alice', email: 'not-an-email', password: 'Secure123' })
      .expect(422);

    expect(res.body.status).toBe('error');
  });

  it('should require authentication', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@example.com', password: 'Secure123' })
      .expect(401);
  });
});

describe('DELETE /api/users/:id', () => {
  it('should return 404 for non-existent user', async () => {
    const fakeId = new mongoose.Types.ObjectId();
    await request(app)
      .delete(`/api/users/${fakeId}`)
      .set('Authorization', `Bearer ${authToken}`)
      .expect(404);
  });
});

Add a Jest configuration to package.json:

{
  "jest": {
    "testEnvironment": "node",
    "testMatch": ["**/tests/**/*.test.js"],
    "setupFiles": ["dotenv/config"],
    "testTimeout": 10000
  }
}

13. Project Structure Best Practices

A well-organized project structure scales as your API grows. Here is the recommended layout for a production Express API:

my-api/
  src/
    app.js              # Express app setup and middleware
    server.js           # HTTP server, port binding, DB connection
    config/
      database.js       # Database connection logic
      index.js          # Environment config loader
    middleware/
      auth.js           # authenticate, authorize
      errorHandler.js   # Global error handler
      validate.js       # Validation middleware factory
      rateLimiter.js    # Rate limiting config
    routes/
      v1/
        index.js        # Route aggregator for v1
        userRoutes.js
        postRoutes.js
        authRoutes.js
    controllers/
      authController.js
      userController.js
      postController.js
    services/
      authService.js    # Business logic for auth
      userService.js    # Business logic for users
      emailService.js   # Email sending logic
    models/
      User.js
      Post.js
    utils/
      AppError.js       # Custom error class
      asyncHandler.js   # Async wrapper
      apiFeatures.js    # Pagination, filtering, sorting helpers
  tests/
    users.test.js
    auth.test.js
    setup.js            # Test setup and teardown
  .env
  .env.example
  package.json
  knexfile.js           # If using Knex/PostgreSQL

Key principles for structuring your project:

14. Deployment Considerations

Moving from development to production requires attention to performance, reliability, and observability.

Process Management

# Use PM2 for production process management
npm install -g pm2

# Start with clustering (uses all CPU cores)
pm2 start src/server.js -i max --name my-api

# Common PM2 commands
pm2 list              # Show running processes
pm2 logs my-api       # View logs
pm2 restart my-api    # Restart
pm2 monit             # Real-time monitoring

Graceful Shutdown

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

// Handle graceful shutdown
const shutdown = (signal) => {
  console.log(`${signal} received. Shutting down gracefully...`);
  server.close(() => {
    console.log('HTTP server closed');
    // Close database connections
    mongoose.connection.close(false, () => {
      console.log('Database connection closed');
      process.exit(0);
    });
  });

  // Force shutdown after 10 seconds
  setTimeout(() => {
    console.error('Forced shutdown');
    process.exit(1);
  }, 10000);
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
  console.error('UNHANDLED REJECTION:', err.message);
  server.close(() => process.exit(1));
});

Docker Deployment

# Dockerfile
FROM node:20-alpine

WORKDIR /app

# Install dependencies first (layer caching)
COPY package*.json ./
RUN npm ci --only=production

# Copy source code
COPY src/ ./src/

# Run as non-root user
USER node

EXPOSE 3000
CMD ["node", "src/server.js"]
# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=mongodb://mongo:27017/myapp
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - mongo
    restart: unless-stopped

  mongo:
    image: mongo:7
    volumes:
      - mongo_data:/data/db
    restart: unless-stopped

volumes:
  mongo_data:

Production Checklist

Frequently Asked Questions

What is the difference between Express.js and Node.js?

Node.js is a JavaScript runtime built on Chrome's V8 engine that lets you run JavaScript on the server. It provides low-level APIs for networking, file system access, and HTTP. Express.js is a minimal web framework that runs on top of Node.js, providing a cleaner API for routing, middleware, request/response handling, and building web applications. You can build HTTP servers with just Node.js, but Express saves you from writing repetitive boilerplate for parsing bodies, routing URLs, and managing middleware chains. Think of Node.js as the engine and Express as the car built around it.

How do I handle errors properly in an Express API?

Express uses error-handling middleware with four parameters: (err, req, res, next). For async handlers, wrap them in try-catch and pass errors to next(), or use an asyncHandler wrapper that catches promise rejections automatically. Define a centralized error handler at the end of your middleware chain that formats all error responses consistently. Create a custom AppError class with status codes and error codes. In production, never expose stack traces to clients. Log errors with a structured logger like Winston or Pino for debugging.

Should I use MongoDB or PostgreSQL with Express.js?

Choose based on your data model. MongoDB with Mongoose is excellent for flexible, document-oriented data and rapid prototyping. Its JSON-like documents map naturally to JavaScript objects. PostgreSQL with Knex or Prisma is better for complex relationships, transactions, and strong data integrity. PostgreSQL is generally the safer default for production because relational data is more common than you might think, and migrating from MongoDB to PostgreSQL later is painful. Many teams start with MongoDB for speed and regret it when they need joins and transactions.

How do I secure a Node.js Express API in production?

Security involves multiple layers. Use helmet for security HTTP headers. Enable CORS with restricted origins. Implement rate limiting with express-rate-limit. Validate all input with express-validator or Joi. Use bcrypt for password hashing with a cost factor of at least 12. Implement JWT with short-lived access tokens and refresh tokens. Always use HTTPS. Sanitize input against NoSQL injection (express-mongo-sanitize) and XSS. Keep dependencies updated with npm audit. Never hardcode secrets — use environment variables.

What is the best project structure for a large Express.js API?

Use a modular structure organized by feature. Each feature module (users, products, orders) contains its own routes, controllers, services, models, and validation. Keep controllers thin — they parse requests and call service functions where business logic lives. Separate app.js (Express setup) from server.js (HTTP server startup) so you can import the app in tests. Use a config directory for environment settings, middleware directory for shared middleware, and utils directory for helpers. This structure scales because adding a feature means adding a directory, not touching existing code.

How do I test an Express.js API?

Use Jest as the test runner and Supertest for HTTP requests against your Express app without starting a real server. Export your app from app.js and pass it to Supertest: const response = await request(app).get('/api/users'). Write unit tests for services and utilities, and integration tests for endpoints. Mock external dependencies with Jest mocks. Use a separate test database or in-memory database. Structure tests to mirror your source directory. Run tests in CI/CD pipelines to catch regressions before deployment.

Conclusion

Building production-ready APIs with Node.js and Express requires more than knowing the framework's syntax. It requires understanding middleware composition, proper error handling, input validation, authentication flows, database patterns, testing strategies, and security hardening. The patterns in this guide represent industry best practices that have been refined across thousands of production deployments.

If you are starting a new API project, follow this progression: set up the project structure with separate app and server files, add core middleware (JSON parsing, CORS, helmet), implement your first CRUD resource with proper error handling, add input validation, implement authentication, write tests for every endpoint, and then layer on rate limiting and security. Each step builds on the previous one, and by the end you have a production-ready foundation.

The Node.js ecosystem moves fast, but Express's minimalist philosophy has kept it relevant for over a decade. The middleware pattern, route handling, and request-response cycle you learn here apply whether you are building a monolith, microservices, or a serverless API. Master these fundamentals and you can build anything.

⚙ Essential tools: Format your API's JSON responses with the JSON Formatter, generate UUIDs for your resources with the UUID Generator, and keep our JavaScript Cheat Sheet nearby for quick syntax lookups.

Related Resources

Related Resources

Flask REST API Guide
Build REST APIs with Python's Flask framework
JavaScript Debugging Guide
Master debugging tools for Node.js and browser
TypeScript Generics Guide
Add type safety to your Express APIs with TypeScript
JSON Formatter
Format, validate, and beautify JSON API responses
JSON Complete Guide
Master JSON for API data and configuration
JavaScript Cheat Sheet
Quick reference for JavaScript syntax and methods