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 };
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.mernblog-api.postman_collection.json.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 |