+ Express
Backend Development

Building RESTful APIs with Node.js and Express

Mayur Dabhi
Mayur Dabhi
February 18, 2026
18 min read

REST (Representational State Transfer) APIs are the backbone of modern web applications. Whether you're building a mobile app, a single-page application, or integrating with third-party services, understanding how to design and implement robust APIs is essential. In this comprehensive guide, we'll build a production-ready RESTful API using Node.js and Express, covering everything from project setup to deployment best practices.

Why Node.js and Express?

Node.js provides a non-blocking, event-driven architecture perfect for I/O-intensive operations like APIs. Express adds a minimal, flexible framework layer that makes routing, middleware, and HTTP handling intuitive. Together, they power APIs for companies like Netflix, PayPal, and LinkedIn.

Understanding REST Architecture

Before diving into code, let's understand what makes an API "RESTful". REST is an architectural style with specific constraints that, when followed, create scalable and maintainable APIs.

Client Web, Mobile, IoT Device HTTP Request GET, POST, PUT, DELETE REST API Server Express Router Middleware Controllers JSON Response Database MongoDB, MySQL, PostgreSQL

REST API architecture: Client-Server communication via HTTP

REST Principles

HTTP Methods Overview

GETRead/Retrieve
POSTCreate New
PUTUpdate/Replace
PATCHPartial Update
DELETERemove

Project Setup

Let's build a complete Users API from scratch. We'll set up the project structure, install dependencies, and configure Express.

1

Initialize the Project

Create a new directory and initialize npm with the necessary packages.

Terminal
# Create project directory
mkdir express-rest-api
cd express-rest-api

# Initialize npm (creates package.json)
npm init -y

# Install dependencies
npm install express mongoose dotenv cors helmet morgan

# Install dev dependencies
npm install -D nodemon

# Create folder structure
mkdir -p src/{config,controllers,middleware,models,routes,utils}
Package Breakdown
  • express: Web framework for Node.js
  • mongoose: MongoDB ODM for data modeling
  • dotenv: Environment variable management
  • cors: Cross-Origin Resource Sharing middleware
  • helmet: Security headers middleware
  • morgan: HTTP request logging
  • nodemon: Auto-restart during development
2

Project Structure

Organize your code for scalability and maintainability.

Project Structure
express-rest-api/
├── src/
│   ├── config/
│   │   └── database.js       # Database connection
│   ├── controllers/
│   │   └── userController.js # Request handlers
│   ├── middleware/
│   │   ├── errorHandler.js   # Global error handling
│   │   └── validate.js       # Request validation
│   ├── models/
│   │   └── User.js           # Mongoose schemas
│   ├── routes/
│   │   ├── index.js          # Route aggregator
│   │   └── userRoutes.js     # User endpoints
│   ├── utils/
│   │   └── ApiError.js       # Custom error class
│   └── app.js                # Express app setup
├── .env                      # Environment variables
├── .gitignore
├── package.json
└── server.js                 # Entry point

Building the Express Server

Let's start by creating the core Express application with essential middleware.

src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// Security middleware
app.use(helmet());

// Enable CORS for all origins (configure for production)
app.use(cors());

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

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

// HTTP request logging
if (process.env.NODE_ENV !== 'test') {
  app.use(morgan('dev'));
}

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

// API routes
app.use('/api/v1', routes);

// 404 handler
app.use((req, res) => {
  res.status(404).json({
    success: false,
    message: `Route ${req.originalUrl} not found`
  });
});

// Global error handler (must be last)
app.use(errorHandler);

module.exports = app;
server.js
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/database');

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

// Connect to database and start server
const startServer = async () => {
  try {
    await connectDB();
    
    app.listen(PORT, () => {
      console.log(`
🚀 Server running in ${process.env.NODE_ENV || 'development'} mode
📡 API available at http://localhost:${PORT}/api/v1
❤️  Health check at http://localhost:${PORT}/health
      `);
    });
  } catch (error) {
    console.error('Failed to start server:', error);
    process.exit(1);
  }
};

// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
  console.error('Unhandled Rejection:', err);
  process.exit(1);
});

startServer();
.env
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/express-api
JWT_SECRET=your-super-secret-key-change-in-production

Database Configuration

Setting up MongoDB with Mongoose for data persistence.

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

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI, {
      // These options are no longer needed in Mongoose 6+
      // but included for older versions
    });

    console.log(`📦 MongoDB Connected: ${conn.connection.host}`);

    // Handle connection events
    mongoose.connection.on('error', (err) => {
      console.error('MongoDB connection error:', err);
    });

    mongoose.connection.on('disconnected', () => {
      console.warn('MongoDB disconnected. Attempting to reconnect...');
    });

    mongoose.connection.on('reconnected', () => {
      console.log('MongoDB reconnected');
    });

    return conn;
  } catch (error) {
    console.error('Database connection failed:', error.message);
    throw error;
  }
};

module.exports = connectDB;

Creating the User Model

Define a schema with validation, hooks, and instance methods.

src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, 'Name is required'],
      trim: true,
      minlength: [2, 'Name must be at least 2 characters'],
      maxlength: [50, 'Name cannot exceed 50 characters']
    },
    email: {
      type: String,
      required: [true, 'Email is required'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [
        /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
        'Please provide a valid email'
      ]
    },
    password: {
      type: String,
      required: [true, 'Password is required'],
      minlength: [8, 'Password must be at least 8 characters'],
      select: false // Don't include in queries by default
    },
    role: {
      type: String,
      enum: ['user', 'admin', 'moderator'],
      default: 'user'
    },
    avatar: {
      type: String,
      default: null
    },
    isActive: {
      type: Boolean,
      default: true
    },
    lastLogin: {
      type: Date,
      default: null
    }
  },
  {
    timestamps: true, // Adds createdAt and updatedAt
    toJSON: { virtuals: true },
    toObject: { virtuals: true }
  }
);

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

// Virtual for user's full profile URL
userSchema.virtual('profileUrl').get(function () {
  return `/users/${this._id}`;
});

// Pre-save hook: Hash password before saving
userSchema.pre('save', async function (next) {
  // Only hash if password is modified
  if (!this.isModified('password')) return next();

  // Hash password with bcrypt
  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Instance method: Compare passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

// Instance method: Return user without sensitive data
userSchema.methods.toPublicJSON = function () {
  const obj = this.toObject();
  delete obj.password;
  delete obj.__v;
  return obj;
};

// Static method: Find by email
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email: email.toLowerCase() });
};

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

Routing and Controllers

Separate routes from business logic using the controller pattern.

Request HTTP Router URL Match Middleware Auth, Validate Controller Logic Response JSON

Request flow: Router → Middleware → Controller → Response

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

// GET /api/v1/users - Get all users
router.get('/', userController.getAllUsers);

// GET /api/v1/users/:id - Get single user
router.get('/:id', userController.getUserById);

// POST /api/v1/users - Create new user
router.post('/', validateUser, userController.createUser);

// PUT /api/v1/users/:id - Update user (full)
router.put('/:id', validateUserUpdate, userController.updateUser);

// PATCH /api/v1/users/:id - Update user (partial)
router.patch('/:id', userController.patchUser);

// DELETE /api/v1/users/:id - Delete user
router.delete('/:id', userController.deleteUser);

module.exports = router;
src/routes/index.js
const express = require('express');
const router = express.Router();
const userRoutes = require('./userRoutes');

// Mount routes
router.use('/users', userRoutes);

// Add more routes here as your API grows
// router.use('/posts', postRoutes);
// router.use('/products', productRoutes);

module.exports = router;

API Endpoints

GET /api/v1/users

Retrieve all users with pagination and filtering support.

GET /api/v1/users/:id

Retrieve a single user by their unique ID.

POST /api/v1/users

Create a new user. Requires name, email, and password in request body.

PUT /api/v1/users/:id

Replace an existing user entirely. All fields required.

DELETE /api/v1/users/:id

Delete a user by ID. Returns 204 No Content on success.

Implementing Controllers

Controllers contain the business logic for each endpoint. They handle the request, interact with the model, and send the response.

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

// Helper for async error handling
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

/**
 * @desc    Get all users
 * @route   GET /api/v1/users
 * @access  Public
 */
exports.getAllUsers = asyncHandler(async (req, res) => {
  // Pagination
  const page = parseInt(req.query.page, 10) || 1;
  const limit = parseInt(req.query.limit, 10) || 10;
  const skip = (page - 1) * limit;

  // Filtering
  const filter = {};
  if (req.query.role) filter.role = req.query.role;
  if (req.query.isActive) filter.isActive = req.query.isActive === 'true';

  // Sorting
  const sort = req.query.sort || '-createdAt';

  // Execute query
  const [users, total] = await Promise.all([
    User.find(filter)
      .select('-password')
      .sort(sort)
      .skip(skip)
      .limit(limit),
    User.countDocuments(filter)
  ]);

  res.status(200).json({
    success: true,
    count: users.length,
    total,
    page,
    pages: Math.ceil(total / limit),
    data: users
  });
});

/**
 * @desc    Get single user by ID
 * @route   GET /api/v1/users/:id
 * @access  Public
 */
exports.getUserById = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id).select('-password');

  if (!user) {
    throw new ApiError(404, `User not found with id: ${req.params.id}`);
  }

  res.status(200).json({
    success: true,
    data: user
  });
});

/**
 * @desc    Create new user
 * @route   POST /api/v1/users
 * @access  Public
 */
exports.createUser = asyncHandler(async (req, res) => {
  const { name, email, password, role } = req.body;

  // Check if user already exists
  const existingUser = await User.findByEmail(email);
  if (existingUser) {
    throw new ApiError(400, 'Email already registered');
  }

  // Create user
  const user = await User.create({
    name,
    email,
    password,
    role: role || 'user'
  });

  res.status(201).json({
    success: true,
    message: 'User created successfully',
    data: user.toPublicJSON()
  });
});

/**
 * @desc    Update user (full replacement)
 * @route   PUT /api/v1/users/:id
 * @access  Public
 */
exports.updateUser = asyncHandler(async (req, res) => {
  const { name, email, role, isActive } = req.body;

  let user = await User.findById(req.params.id);

  if (!user) {
    throw new ApiError(404, `User not found with id: ${req.params.id}`);
  }

  // Check email uniqueness if changed
  if (email !== user.email) {
    const emailExists = await User.findByEmail(email);
    if (emailExists) {
      throw new ApiError(400, 'Email already in use');
    }
  }

  // Update fields
  user.name = name;
  user.email = email;
  user.role = role || user.role;
  user.isActive = isActive !== undefined ? isActive : user.isActive;

  await user.save();

  res.status(200).json({
    success: true,
    message: 'User updated successfully',
    data: user.toPublicJSON()
  });
});

/**
 * @desc    Partial update user
 * @route   PATCH /api/v1/users/:id
 * @access  Public
 */
exports.patchUser = asyncHandler(async (req, res) => {
  const updates = req.body;
  const allowedUpdates = ['name', 'email', 'role', 'isActive', 'avatar'];
  
  // Filter out non-allowed fields
  const filteredUpdates = Object.keys(updates)
    .filter(key => allowedUpdates.includes(key))
    .reduce((obj, key) => {
      obj[key] = updates[key];
      return obj;
    }, {});

  if (Object.keys(filteredUpdates).length === 0) {
    throw new ApiError(400, 'No valid fields to update');
  }

  const user = await User.findByIdAndUpdate(
    req.params.id,
    filteredUpdates,
    { new: true, runValidators: true }
  ).select('-password');

  if (!user) {
    throw new ApiError(404, `User not found with id: ${req.params.id}`);
  }

  res.status(200).json({
    success: true,
    message: 'User updated successfully',
    data: user
  });
});

/**
 * @desc    Delete user
 * @route   DELETE /api/v1/users/:id
 * @access  Public
 */
exports.deleteUser = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);

  if (!user) {
    throw new ApiError(404, `User not found with id: ${req.params.id}`);
  }

  await user.deleteOne();

  res.status(204).send();
});

Error Handling

Proper error handling ensures your API responds gracefully to failures. We'll create a custom error class and a global error handler.

src/utils/ApiError.js
class ApiError extends Error {
  constructor(statusCode, message, errors = []) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    this.errors = errors;

    Error.captureStackTrace(this, this.constructor);
  }

  // Factory methods for common errors
  static badRequest(message, errors) {
    return new ApiError(400, message, errors);
  }

  static unauthorized(message = 'Unauthorized') {
    return new ApiError(401, message);
  }

  static forbidden(message = 'Forbidden') {
    return new ApiError(403, message);
  }

  static notFound(message = 'Resource not found') {
    return new ApiError(404, message);
  }

  static internal(message = 'Internal server error') {
    return new ApiError(500, message);
  }
}

module.exports = ApiError;
src/middleware/errorHandler.js
const ApiError = require('../utils/ApiError');

const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;
  error.stack = err.stack;

  // Log error for debugging
  console.error('Error:', {
    message: err.message,
    stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
  });

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    error = new ApiError(400, `Invalid ${err.path}: ${err.value}`);
  }

  // Mongoose duplicate key
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    error = new ApiError(400, `${field} already exists`);
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map(e => e.message);
    error = new ApiError(400, 'Validation failed', messages);
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    error = new ApiError(401, 'Invalid token');
  }

  if (err.name === 'TokenExpiredError') {
    error = new ApiError(401, 'Token expired');
  }

  // Send response
  res.status(error.statusCode || 500).json({
    success: false,
    message: error.message || 'Internal server error',
    errors: error.errors || [],
    ...(process.env.NODE_ENV === 'development' && {
      stack: error.stack
    })
  });
};

module.exports = errorHandler;

Request Validation

Always validate incoming data before processing. This prevents bad data from reaching your database and provides helpful error messages.

src/middleware/validate.js
const ApiError = require('../utils/ApiError');

// Validation helper
const validate = (schema) => {
  return (req, res, next) => {
    const errors = [];

    for (const [field, rules] of Object.entries(schema)) {
      const value = req.body[field];

      // Required check
      if (rules.required && (!value || value.toString().trim() === '')) {
        errors.push(`${field} is required`);
        continue;
      }

      // Skip other validations if field is empty and not required
      if (!value && !rules.required) continue;

      // Type checks
      if (rules.type === 'email') {
        const emailRegex = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
        if (!emailRegex.test(value)) {
          errors.push(`${field} must be a valid email`);
        }
      }

      // Min length
      if (rules.minLength && value.length < rules.minLength) {
        errors.push(`${field} must be at least ${rules.minLength} characters`);
      }

      // Max length
      if (rules.maxLength && value.length > rules.maxLength) {
        errors.push(`${field} cannot exceed ${rules.maxLength} characters`);
      }

      // Enum check
      if (rules.enum && !rules.enum.includes(value)) {
        errors.push(`${field} must be one of: ${rules.enum.join(', ')}`);
      }
    }

    if (errors.length > 0) {
      return next(new ApiError(400, 'Validation failed', errors));
    }

    next();
  };
};

// User validation schemas
exports.validateUser = validate({
  name: { required: true, minLength: 2, maxLength: 50 },
  email: { required: true, type: 'email' },
  password: { required: true, minLength: 8 },
  role: { enum: ['user', 'admin', 'moderator'] }
});

exports.validateUserUpdate = validate({
  name: { required: true, minLength: 2, maxLength: 50 },
  email: { required: true, type: 'email' },
  role: { enum: ['user', 'admin', 'moderator'] }
});
Pro Tip: Use a Validation Library

For production applications, consider using libraries like joi or express-validator for more robust validation with complex rules, nested objects, and custom validators.

Understanding Middleware

Middleware functions have access to the request, response, and the next middleware in the stack. They're the backbone of Express applications.

Request Middleware 1 helmet() next() → Middleware 2 cors() next() → Middleware 3 json() next() → Route Handler Each middleware calls next() to pass control to the next function

Middleware chain: Request flows through each middleware before reaching the route handler

Authentication Middleware Example

src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');

// Protect routes - require authentication
exports.protect = async (req, res, next) => {
  try {
    let token;

    // Get token from header
    if (req.headers.authorization?.startsWith('Bearer')) {
      token = req.headers.authorization.split(' ')[1];
    }

    if (!token) {
      throw new ApiError(401, 'Access denied. No token provided.');
    }

    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Get user from token
    const user = await User.findById(decoded.id).select('-password');

    if (!user) {
      throw new ApiError(401, 'User no longer exists');
    }

    if (!user.isActive) {
      throw new ApiError(401, 'User account is deactivated');
    }

    // Attach user to request
    req.user = user;
    next();
  } catch (error) {
    next(error);
  }
};

// Restrict to certain roles
exports.restrictTo = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return next(
        new ApiError(403, 'You do not have permission to perform this action')
      );
    }
    next();
  };
};

// Usage in routes:
// router.get('/admin', protect, restrictTo('admin'), adminController);

Rate Limiting Middleware

src/middleware/rateLimit.js
// Simple in-memory rate limiter
// For production, use redis-based solution
const rateLimit = (options = {}) => {
  const {
    windowMs = 15 * 60 * 1000, // 15 minutes
    max = 100, // Limit each IP to 100 requests per window
    message = 'Too many requests, please try again later.'
  } = options;

  const requests = new Map();

  // Cleanup old entries periodically
  setInterval(() => {
    const now = Date.now();
    for (const [key, data] of requests.entries()) {
      if (now - data.startTime > windowMs) {
        requests.delete(key);
      }
    }
  }, windowMs);

  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const record = requests.get(key);

    if (!record) {
      requests.set(key, { count: 1, startTime: now });
      return next();
    }

    if (now - record.startTime > windowMs) {
      // Reset window
      requests.set(key, { count: 1, startTime: now });
      return next();
    }

    record.count++;

    // Set rate limit headers
    res.set({
      'X-RateLimit-Limit': max,
      'X-RateLimit-Remaining': Math.max(0, max - record.count),
      'X-RateLimit-Reset': new Date(record.startTime + windowMs).toISOString()
    });

    if (record.count > max) {
      return res.status(429).json({
        success: false,
        message
      });
    }

    next();
  };
};

module.exports = rateLimit;

// Usage:
// app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));

Testing the API

Let's test our API using curl commands. Make sure your server is running with npm run dev.

Terminal - Testing Commands
# Create a user
curl -X POST http://localhost:3000/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "securepassword123"
  }'

# Get all users
curl http://localhost:3000/api/v1/users

# Get all users with pagination
curl "http://localhost:3000/api/v1/users?page=1&limit=5"

# Get single user (replace :id with actual ID)
curl http://localhost:3000/api/v1/users/65abc123def456

# Update user
curl -X PUT http://localhost:3000/api/v1/users/65abc123def456 \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Updated",
    "email": "john.updated@example.com",
    "role": "admin"
  }'

# Partial update
curl -X PATCH http://localhost:3000/api/v1/users/65abc123def456 \
  -H "Content-Type: application/json" \
  -d '{"isActive": false}'

# Delete user
curl -X DELETE http://localhost:3000/api/v1/users/65abc123def456
Recommended Tools for API Testing
  • Postman: Feature-rich GUI for testing APIs
  • Insomnia: Clean, fast REST client
  • Thunder Client: VS Code extension for API testing
  • HTTPie: User-friendly command-line HTTP client

API Response Standards

Consistent response formats make your API predictable and easier to consume.

Status Code Meaning When to Use
200 OK Success GET, PUT, PATCH successful
201 Created Resource created POST successful
204 No Content Success, no body DELETE successful
400 Bad Request Invalid input Validation failed
401 Unauthorized Not authenticated Missing/invalid token
403 Forbidden Not authorized Insufficient permissions
404 Not Found Resource doesn't exist ID not found
500 Internal Error Server error Unexpected failures

Best Practices Summary

Production Checklist

  • Version your API: Use /api/v1/ prefix for easy upgrades
  • Use nouns for resources: /users not /getUsers
  • Support filtering, sorting, pagination: Query params for list endpoints
  • Implement rate limiting: Protect against abuse
  • Use HTTPS: Encrypt all traffic in production
  • Add request logging: Morgan or similar for debugging
  • Document your API: Swagger/OpenAPI specification
  • Write tests: Unit and integration tests with Jest
  • Use environment variables: Never hardcode secrets
  • Implement health checks: /health endpoint for monitoring
Security Reminders
  • Always validate and sanitize user input
  • Use parameterized queries to prevent injection
  • Implement proper authentication and authorization
  • Set security headers with helmet
  • Keep dependencies updated
  • Never expose stack traces in production

Next Steps

You now have a solid foundation for building RESTful APIs with Node.js and Express! Here's what to explore next:

Building APIs is both an art and a science. Focus on consistency, security, and developer experience. Happy coding!

Node.js Express REST API Backend JavaScript MongoDB
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about building scalable web applications. Sharing knowledge about modern development practices.