+ Express
Backend Development

Building RESTful APIs with Node.js and Express

Mayur Dabhi
Mayur Dabhi
February 18, 2026
18 min read

Most Express APIs start as a single app.js with a few routes, and that's fine for a weekend. Then the frontend team asks why a missing user returns a 200 with an empty body, a bad ID crashes the process, and every endpoint formats errors differently. By that point, restructuring hurts.

This post builds a small Users API with Node.js, Express and MongoDB, set up the way I'd want it from day one: a sensible folder layout, a model with validation, controllers kept apart from routes, one error handler for everything, and a few middleware pieces (auth, rate limiting) you'll need sooner or later.

Why Node.js and Express?

An API spends most of its time waiting on the database or other services, and Node's non-blocking I/O handles that kind of workload well. Express stays out of the way: it gives you routing and a middleware chain and leaves the rest of the decisions to you. That's a plus when you know what you want, and a minus when you'd rather have conventions decided for you.

Understanding REST Architecture

"RESTful" gets used loosely. In practice it means a handful of conventions: URLs name resources, HTTP methods say what you're doing to them, and the server doesn't keep per-client session state between requests. Follow those and other developers can guess how your API works before reading the docs.

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

The example is a Users API with full CRUD. Start with the dependencies and the folder layout.

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

Group files by role so you know where a new route or model goes.

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

I keep the Express app in src/app.js and the code that starts listening in server.js. The split pays off when you write tests: Supertest can import the app without opening a port or connecting to the database. Order matters in this file, since middleware runs top to bottom and the error handler has to be last.

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

The connection helper connects once at startup and logs connection events, so you can see in the logs when MongoDB drops and comes back.

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

The schema handles field validation, hashes the password in a pre-save hook, and adds a couple of helper methods. Note select: false on the password: queries leave it out unless you ask for it explicitly. The model uses bcryptjs, which wasn't in the install command above, so run npm install bcryptjs too.

Gotcha: duplicate index warning

Setting unique: true on email already creates an index. Declaring userSchema.index({ email: 1 }) as well makes recent Mongoose versions log Duplicate schema index on {"email":1} found at startup. It's harmless but noisy, so drop one of the two. Also remember unique isn't a validator. If duplicate emails already exist in the collection, the index build fails and the constraint quietly doesn't apply.

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

Route files only map URLs and methods to handlers. The actual work happens in controllers, which keeps the route file readable as a list of endpoints.

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

Request flow: router, then middleware, then controller, then 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

Each controller reads the request, talks to the model, and sends a response. The asyncHandler wrapper at the top matters more than it looks. Express 4 doesn't catch rejected promises from async handlers, so without it a thrown error leaves the request hanging until the client times out. Express 5 handles this natively, but plenty of projects are still on 4.

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

Two pieces: an ApiError class you throw from controllers, and one error middleware that turns anything thrown (your errors, Mongoose errors, JWT errors) into the same JSON shape. Express recognizes error middleware by its four arguments, so keep next in the signature even if a linter complains it's unused.

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

Validate the request body before it reaches the controller. Mongoose validation will catch some of it, but by then you've already hit the database, and its error messages aren't always ones you want to show clients.

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'] }
});
Tip: Use a Validation Library

The hand-rolled validator is fine for learning, but it falls apart with nested objects and arrays. For real projects I'd reach for joi, zod or express-validator.

Understanding Middleware

Nearly everything in Express is middleware: a function that gets req, res and next, does something, and either sends a response or calls next(). Forget both and the request just hangs, which is one of the more confusing bugs to track down.

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

Start the server with npm run dev (add "dev": "nodemon server.js" to your package.json scripts first) and try the endpoints with curl. If a POST comes back with "name is required" even though you sent a name, check that you passed the Content-Type: application/json header. Without it, express.json() skips the body and req.body stays empty.

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: Full GUI client with collections and environments
  • Insomnia: Clean, fast REST client
  • Thunder Client: VS Code extension for API testing
  • HTTPie: User-friendly command-line HTTP client

API Response Standards

Clients branch on the status code before they look at the body, so get these right. Returning 200 with { success: false } forces every client to parse the body just to find out something failed.

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

Authentication with JWT

Since a stateless API doesn't keep server-side sessions, the common approach is to issue a signed JWT on login and require it in the Authorization: Bearer <token> header on protected routes.

JWT Security

Avoid keeping JWTs in localStorage, since any XSS on the page can read them. For browser clients, an httpOnly cookie is safer. Keep access tokens short-lived (15 minutes is common) and use refresh tokens for longer sessions.

Node.js: JWT authentication middleware
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET; // keep this out of source control!

// ── Issue a token on login ────────────────────────────────────
app.post('/api/v1/auth/login', (req, res) => {
    const { email, password } = req.body;

    // TODO: look up user in database and verify password hash
    const user = { id: 1, email, role: 'editor' };

    const token = jwt.sign(
        { sub: user.id, email: user.email, role: user.role },
        JWT_SECRET,
        { expiresIn: '15m' }
    );

    res.json({ status: 'success', token });
});

// ── Auth middleware ───────────────────────────────────────────
function authenticate(req, res, next) {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({ status: 'error', message: 'Missing token' });
    }

    try {
        const token = authHeader.split(' ')[1];
        req.user = jwt.verify(token, JWT_SECRET);
        next();
    } catch (err) {
        res.status(401).json({ status: 'error', message: 'Invalid or expired token' });
    }
}

// ── Protect routes ───────────────────────────────────────────
app.post('/api/v1/posts', authenticate, (req, res) => { /* ... */ });
app.put('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });
app.delete('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });

What to Check Before Going to Production

The code above works locally. A few things in it need changing before real traffic hits it:

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

When I'd Use Plain Express (and When I Wouldn't)

For a small or medium API, or a service with a narrow job, this setup is plenty. You can read every line of it and there's nothing magic going on.

The cost is that you write and maintain all the glue yourself: validation, error formatting, auth, docs. On a larger team that turns into five slightly different ways of doing the same thing. If you want structure enforced for you, NestJS (which runs on Express by default) or Fastify with its schema-based validation will save you from rebuilding it. If you're working in TypeScript, typing req.body in plain Express takes extra work that those frameworks largely handle for you.

I'd also skip REST altogether when the frontend keeps needing differently shaped slices of deeply related data. That's where GraphQL earns its complexity. For straightforward CRUD like the users example here, it's overkill.

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.