With the project structure in place and the database connected, it is time to build the full MERN Blog REST API — controllers and routes for authentication and posts. This lesson is the integration point where everything from Parts 2 and 3 comes together: Express routing, Mongoose models, JWT middleware, express-validator, and the error handling layer all working in concert to produce a complete, tested API that React will consume in Part 4.
Auth Controller — register and login
// server/src/controllers/authController.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const AppError = require('../utils/AppError');
const asyncHandler = require('../utils/asyncHandler');
// ── Helper: sign and return a JWT ─────────────────────────────────────────────
const signToken = (userId) =>
jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
});
const sendAuthResponse = (res, statusCode, user, message) => {
const token = signToken(user._id);
res.status(statusCode).json({
success: true,
message,
token,
data: {
_id: user._id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
},
});
};
// @desc Register a new user
// @route POST /api/auth/register
// @access Public
const register = asyncHandler(async (req, res) => {
const { name, email, password } = req.body;
// Check for existing user before creating
const existing = await User.exists({ email });
if (existing) throw new AppError('Email already registered', 409);
const user = await User.create({ name, email, password });
// pre('save') hook hashes password automatically
sendAuthResponse(res, 201, user, 'Account created successfully');
});
// @desc Login with email and password
// @route POST /api/auth/login
// @access Public
const login = asyncHandler(async (req, res) => {
const { email, password } = req.body;
// select('+password') required because password has select: false in schema
const user = await User.findOne({ email }).select('+password');
if (!user) throw new AppError('Invalid email or password', 401);
const isMatch = await user.comparePassword(password);
if (!isMatch) throw new AppError('Invalid email or password', 401);
sendAuthResponse(res, 200, user, 'Logged in successfully');
});
// @desc Get currently authenticated user
// @route GET /api/auth/me
// @access Protected
const getMe = asyncHandler(async (req, res) => {
// req.user is attached by the protect middleware
const user = await User.findById(req.user.id).populate('postCount');
res.json({ success: true, data: user });
});
module.exports = { register, login, getMe };
sendAuthResponse helper is a pattern worth adopting — it centralises the shape of every auth response into one place. If you later need to add a refresh token, change the token expiry format, or add a cookie, you change it once in this helper rather than in every auth endpoint. The same principle applies to any response shape that appears more than twice in your API.select('+password') explicitly in the login query — and only in the login query. Because the password field has select: false in the User schema, it is excluded from every other query by default. If you copy-paste a User.findOne() call from the login controller into a profile controller, the password will be included unless you remember to add .select('-password').Post Controller — Full CRUD
// server/src/controllers/postController.js
const Post = require('../models/Post');
const AppError = require('../utils/AppError');
const asyncHandler = require('../utils/asyncHandler');
// @desc Get all published posts (paginated, filterable)
// @route GET /api/posts
// @access Public
const getAllPosts = asyncHandler(async (req, res) => {
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(50, parseInt(req.query.limit, 10) || 10);
const skip = (page - 1) * limit;
const sort = req.query.sort || 'createdAt';
const order = req.query.order === 'asc' ? 1 : -1;
const filter = { published: true };
if (req.query.tag) filter.tags = req.query.tag;
if (req.query.author) filter.author = req.query.author;
if (req.query.search) filter.$text = { $search: req.query.search };
const [posts, total] = await Promise.all([
Post.find(filter)
.sort({ [sort]: order })
.skip(skip)
.limit(limit)
.populate('author', 'name avatar')
.lean(),
Post.countDocuments(filter),
]);
res.json({
success: true, count: posts.length, total,
page, pages: Math.ceil(total / limit), data: posts,
});
});
// @desc Get single post by ID
// @route GET /api/posts/:id
// @access Public
const getPostById = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id)
.populate('author', 'name avatar bio');
if (!post) throw new AppError('Post not found', 404);
if (!post.published && (!req.user || req.user.id !== post.author._id.toString())) {
throw new AppError('Post not found', 404);
}
Post.findByIdAndUpdate(req.params.id, { $inc: { viewCount: 1 } }).exec();
res.json({ success: true, data: post });
});
// @desc Create a new post
// @route POST /api/posts
// @access Protected
const createPost = asyncHandler(async (req, res) => {
const { title, body, excerpt, tags, published, coverImage } = req.body;
const post = await Post.create({
title, body, excerpt, tags, published, coverImage,
author: req.user.id,
});
res.status(201).json({ success: true, data: post });
});
// @desc Update a post
// @route PATCH /api/posts/:id
// @access Protected (owner or admin)
const updatePost = asyncHandler(async (req, res) => {
let post = await Post.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404);
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
throw new AppError('Not authorised to update this post', 403);
}
const { title, body, excerpt, tags, published, coverImage, featured } = req.body;
const updates = {};
[['title', title], ['body', body], ['excerpt', excerpt], ['tags', tags],
['published', published], ['coverImage', coverImage], ['featured', featured]]
.forEach(([k, v]) => { if (v !== undefined) updates[k] = v; });
post = await Post.findByIdAndUpdate(req.params.id, { $set: updates },
{ new: true, runValidators: true }).populate('author', 'name avatar');
res.json({ success: true, data: post });
});
// @desc Delete a post
// @route DELETE /api/posts/:id
// @access Protected (owner or admin)
const deletePost = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404);
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
throw new AppError('Not authorised to delete this post', 403);
}
await post.deleteOne();
res.json({ success: true, message: 'Post deleted successfully' });
});
module.exports = { getAllPosts, getPostById, createPost, updatePost, deletePost };
Auth and Posts Routes
// server/src/routes/auth.js
const express = require('express');
const router = express.Router();
const { register, login, getMe } = require('../controllers/authController');
const protect = require('../middleware/auth');
const validate = require('../middleware/validate');
const { registerRules, loginRules } = require('../validators/authValidators');
router.post('/register', ...registerRules, validate, register);
router.post('/login', ...loginRules, validate, login);
router.get('/me', protect, getMe);
module.exports = router;
// server/src/routes/posts.js
const express = require('express');
const router = express.Router();
const { getAllPosts, getPostById, createPost, updatePost, deletePost }
= require('../controllers/postController');
const protect = require('../middleware/auth');
const validateObjectId = require('../middleware/validateObjectId');
const validate = require('../middleware/validate');
const { createPostRules, updatePostRules } = require('../validators/postValidators');
router.route('/')
.get(getAllPosts)
.post(protect, ...createPostRules, validate, createPost);
router.route('/:id')
.get(validateObjectId('id'), getPostById)
.patch(protect, validateObjectId('id'), ...updatePostRules, validate, updatePost)
.delete(protect, validateObjectId('id'), deletePost);
module.exports = router;
Common Mistakes
Mistake 1 — Not returning after sending a response in a controller
❌ Wrong — execution continues after res.json(), causing “headers already sent”:
const login = asyncHandler(async (req, res) => {
const user = await User.findOne({ email }).select('+password');
if (!user) res.status(401).json({ message: 'Not found' }); // no return!
// execution continues here even when user is null → crash
const match = await user.comparePassword(password);
});
✅ Correct — always return after sending a response:
if (!user) return res.status(401).json({ message: 'Not found' }); // ✓
// or: throw new AppError('Not found', 401); — asyncHandler handles the rest
Mistake 2 — Setting author from req.body instead of req.user
❌ Wrong — any authenticated user can claim to be any author:
const post = await Post.create({ ...req.body }); // req.body.author could be forged
✅ Correct — always set server-controlled fields from middleware, not from req.body:
const { title, body } = req.body;
const post = await Post.create({ title, body, author: req.user.id }); // ✓
Mistake 3 — Leaking whether an email exists during login
❌ Wrong — different error messages for wrong email vs wrong password:
if (!user) throw new AppError('Email not found', 404); // reveals email exists
if (!isMatch) throw new AppError('Wrong password', 401); // reveals email exists
✅ Correct — always return the same generic message:
if (!user || !await user.comparePassword(password)) {
throw new AppError('Invalid email or password', 401); // ✓ no information leak
}
Quick Reference
| Endpoint | Controller Function | Auth |
|---|---|---|
| POST /api/auth/register | register | Public |
| POST /api/auth/login | login | Public |
| GET /api/auth/me | getMe | protect |
| GET /api/posts | getAllPosts | Public |
| POST /api/posts | createPost | protect |
| GET /api/posts/:id | getPostById | Public |
| PATCH /api/posts/:id | updatePost | protect + owner/admin |
| DELETE /api/posts/:id | deletePost | protect + owner/admin |