Building a Model-Based REST API — Controllers and Routes

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 };
Note: The 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.
Tip: Never tell the user which of “email” or “password” was wrong in a login error. Returning “Email not found” lets an attacker enumerate valid email addresses. Always return a generic “Invalid email or password” message for both cases — this is a security best practice called not leaking information.
Warning: Always use 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

🧠 Test Yourself

In your login controller you return “Email not found” when no user matches the email, and “Wrong password” when the email exists but the password is wrong. A security researcher flags this as a vulnerability. Why?