Building the Complete Express API

The Express API is the backbone of the capstone โ€” it handles authentication, data persistence, file uploads, real-time communication, and email. Building it correctly means applying every principle from the server-side chapters: consistent error handling with AppError and asyncHandler, validation with express-validator before controllers run, JWT auth with protect and authorize middleware, Mongoose models with virtual fields and pre-save hooks, and a clean separation of routes, controllers, and services. In this lesson you will assemble the complete Express API, verify every endpoint with Postman, and confirm the API is ready for React integration.

The Express API Entry Point

// server/index.js
require('dotenv').config();
const express    = require('express');
const http       = require('http');
const { Server } = require('socket.io');
const cors       = require('cors');
const helmet     = require('helmet');
const compression = require('compression');
const rateLimit  = require('express-rate-limit');
const connectDB  = require('./src/config/db');
const { initSocket, setupSocketHandlers } = require('./src/config/socket');
const errorHandler = require('./src/middleware/errorHandler');
const cookieParser = require('cookie-parser');

const app    = express();
const server = http.createServer(app);

// โ”€โ”€ Socket.io โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const io = new Server(server, {
  cors: { origin: process.env.CLIENT_URL, credentials: true },
});
initSocket(io);
setupSocketHandlers(io);

// โ”€โ”€ Middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use(compression());
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// โ”€โ”€ Rate limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 200 }));
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 15 }));

// โ”€โ”€ Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api/auth',    require('./src/routes/auth'));
app.use('/api/posts',   require('./src/routes/posts'));
app.use('/api/users',   require('./src/routes/users'));
app.use('/api/admin',   require('./src/routes/admin'));

// โ”€โ”€ Error Handler (must be last) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(errorHandler);

// โ”€โ”€ Start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const start = async () => {
  await connectDB();
  if (require.main === module) {
    server.listen(process.env.PORT || 5000,
      () => console.log(`Server running on port ${process.env.PORT || 5000}`));
  }
};
start();

module.exports = { app, server };
Note: The require.main === module guard prevents the server from starting a listening port when the module is imported by tests (Supertest imports the app without calling listen). This is the standard pattern for making an Express app both testable and runnable โ€” export the app for tests, guard the listen call for production. Without this guard, running Jest causes “EADDRINUSE: address already in use” errors when multiple test files import the app.
Tip: Build and test each route group in Postman before moving to the next. Create a Postman collection with folders for Auth, Posts, Comments, and Users. Save test requests with example request bodies and expected responses. This collection doubles as documentation for the API and makes it easy to verify that a change in one controller did not break another endpoint. Export the collection to the repository as mernblog-api.postman_collection.json.
Warning: The middleware order in Express is critical and specific mistakes are hard to debug. The order must be: security headers (helmet) โ†’ body parsers โ†’ CORS โ†’ rate limiting โ†’ routes โ†’ error handler. The error handler must always be last โ€” it will not catch errors from routes defined after it. CORS must come before routes โ€” otherwise preflight OPTIONS requests will be rejected before reaching any CORS configuration.

The Post Model โ€” Complete

// server/src/models/Post.js
const mongoose = require('mongoose');
const slugify  = require('slugify'); // npm install slugify

const postSchema = new mongoose.Schema({
  title:      { type: String, required: [true, 'Title is required'], trim: true, maxlength: 200 },
  slug:       { type: String, unique: true, index: true },
  body:       { type: String, required: [true, 'Body is required'], minlength: 10 },
  excerpt:    { type: String, maxlength: 500, default: '' },
  coverImage: { type: String, default: '' },
  author:     { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  tags:       [{ type: String, lowercase: true, trim: true }],
  published:  { type: Boolean, default: false },
  featured:   { type: Boolean, default: false },
  viewCount:  { type: Number, default: 0 },
  likedBy:    [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
}, { timestamps: true });

// Auto-generate slug from title before saving
postSchema.pre('save', async function (next) {
  if (!this.isModified('title')) return next();
  let base = slugify(this.title, { lower: true, strict: true });
  let slug = base;
  let count = 1;
  // Ensure uniqueness
  while (await mongoose.models.Post.exists({ slug, _id: { $ne: this._id } })) {
    slug = `${base}-${count++}`;
  }
  this.slug = slug;
  next();
});

// Virtual: like count
postSchema.virtual('likeCount').get(function () {
  return this.likedBy.length;
});

// Text index for full-text search
postSchema.index({ title: 'text', body: 'text', tags: 'text' });

// Index for common queries
postSchema.index({ published: 1, createdAt: -1 });
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1 });

module.exports = mongoose.model('Post', postSchema);

Key Post Controller Patterns

// server/src/controllers/postController.js (selected endpoints)
const Post = require('../models/Post');
const asyncHandler = require('../utils/asyncHandler');
const AppError     = require('../utils/AppError');

// GET /api/posts โ€” paginated, filterable, searchable
const getPosts = asyncHandler(async (req, res) => {
  const { page = 1, limit = 10, tag, search, author, featured } = req.query;
  const query = { published: true };
  if (tag)      query.tags    = tag.toLowerCase();
  if (author)   query.author  = author;
  if (featured) query.featured = true;
  if (search)   query.$text   = { $search: search };

  const skip  = (Number(page) - 1) * Number(limit);
  const [posts, total] = await Promise.all([
    Post.find(query)
      .sort(search ? { score: { $meta: 'textScore' } } : { createdAt: -1 })
      .skip(skip)
      .limit(Number(limit))
      .populate('author', 'name avatar')
      .select('-body -likedBy'), // exclude large fields from list view
    Post.countDocuments(query),
  ]);

  res.json({ success: true, data: posts, total, page: Number(page),
             pages: Math.ceil(total / Number(limit)) });
});

// PATCH /api/posts/:id/like โ€” toggle like
const likePost = asyncHandler(async (req, res) => {
  const post    = await Post.findById(req.params.id);
  if (!post) throw new AppError('Post not found', 404);

  const userId  = req.user._id.toString();
  const liked   = post.likedBy.map(id => id.toString()).includes(userId);

  if (liked) {
    post.likedBy.pull(req.user._id);    // unlike
  } else {
    post.likedBy.addToSet(req.user._id); // like
  }
  await post.save({ validateBeforeSave: false });

  // Broadcast the updated count via Socket.io
  const { getIO } = require('../config/socket');
  getIO().to(`post:${req.params.id}`).emit('like-update', {
    likeCount: post.likedBy.length,
    liked:     !liked,
  });

  res.json({ success: true, data: { likeCount: post.likedBy.length, liked: !liked } });
});

API Verification Checklist

Endpoint Test Expected
POST /api/auth/register Valid data 201 + token
POST /api/auth/register Duplicate email 409
POST /api/auth/login Wrong password 401
GET /api/auth/me No token 401
GET /api/auth/me Valid token 200 + user
POST /api/posts No token 401
POST /api/posts Valid token + data 201 + post
GET /api/posts ?tag=mern 200 + filtered posts
DELETE /api/posts/:id Different user’s token 403
GET /api/health No auth 200 + { status: ‘ok’ }

Common Mistakes

Mistake 1 โ€” Not excluding sensitive fields from API responses

โŒ Wrong โ€” password hash returned in user data:

const user = await User.findById(id);
res.json({ data: user }); // password hash visible to the client!

โœ… Correct โ€” always exclude sensitive fields:

const user = await User.findById(id).select('-password -emailVerifyToken -passwordResetToken');
res.json({ data: user }); // โœ“ no sensitive fields

Mistake 2 โ€” Forgetting to populate author on post responses

โŒ Wrong โ€” author is just an ObjectId string in the response:

const post = await Post.findById(id);
res.json({ data: post }); // author: '64a1f2b3...' โ€” React cannot show name or avatar

โœ… Correct โ€” populate to get the author details:

const post = await Post.findById(id).populate('author', 'name avatar');
res.json({ data: post }); // โœ“ author: { name: 'Jane', avatar: 'https://...' }

Mistake 3 โ€” Not returning total count for paginated responses

โŒ Wrong โ€” React cannot build a Pagination component without the total:

res.json({ success: true, data: posts }); // no total or page count

โœ… Correct โ€” include pagination metadata:

res.json({ success: true, data: posts, total, page, pages }); // โœ“

Quick Reference โ€” Essential Packages

Package Purpose
express Web framework
mongoose MongoDB ODM
bcryptjs Password hashing
jsonwebtoken JWT sign and verify
express-validator Request validation
multer + multer-storage-cloudinary File uploads
socket.io Real-time WebSocket events
nodemailer Email sending
slugify URL-safe post slugs
express-rate-limit Rate limiting
helmet Security headers
compression Gzip response compression
cookie-parser Parse HttpOnly cookie for refresh token

🧠 Test Yourself

You call GET /api/posts and the response includes posts where author is just the ObjectId string "64a1f2b3...". The PostCard component cannot display the author’s name. What is the Express-side fix?