๐ Weekend Project: Build a Database-Backed Blog API
Last weekend your task API stored everything in an array that a single restart wiped clean. This weekend you fix that for real. You'll build a Blog REST API whose Posts and Comments live in an actual database โ MongoDB, driven through Mongoose โ so your data survives restarts, deploys, and crashes. You'll model two resources that are genuinely related (every comment belongs to a post), wire full CRUD for both, validate at the schema level, join them together with populate(), and give the list endpoint the pagination, filtering, and sorting a real client expects. This is the moment your backend grows up.
Week 8 · Weekend Project · Databases Capstone
๐ฏ Learning Objectives
By completing this project, you will be able to:
- Connect an Express app to MongoDB with Mongoose 7+ using an environment-variable connection string and an
asyncconnect helper - Define schemas and models for two related resources (Posts and Comments) with built-in validation, defaults, and timestamps
- Model a real one-to-many relationship with an
ObjectIdreference and resolve it with.populate() - Implement full CRUD for both resources on correct REST routes, returning honest status codes (200, 201, 204, 400, 404, 422)
- Add pagination, filtering, and sorting to the posts list using Mongoose query methods (
skip,limit,sort) plus a total count - Translate Mongoose validation and cast errors into clean responses through one centralized error handler
- Test every endpoint from the terminal with
curland read the status codes it returns
Estimated Time: 6โ9 hours across the weekend
Project: A runnable, database-backed Express API with full-CRUD /api/posts and nested /api/posts/:postId/comments resources, schema validation, a populated relationship, and a paginated/filterable/sortable posts list.
In This Project
The Goal
Build a backend that manages blog posts and their comments over HTTP, storing both in MongoDB. A client sends POST /api/posts with a JSON body; your API validates it, saves a document, and answers 201 Created with the new post โ including a real database _id. Ask for GET /api/posts?status=published&sort=-createdAt&page=2&limit=10 and it returns the second page of published posts, newest first, plus metadata. Fetch a single post and its comments come along for the ride, each joined to the post through a stored reference. Send an empty title and you get 422 with a precise, per-field error list โ never a stack trace, never a crash.
The shape is the same production pipeline you built last week โ body parsing, routing, validation, controller, response, and one error handler at the end โ but now the controller talks to a database instead of an array. That one change ripples outward: the model defines and enforces the data's shape, the connection must be established before the server accepts traffic, and a new class of errors (cast failures, duplicate keys, validation) has to be caught and translated.
match /api/posts"] R --> V["validation
express-validator"] V --> C["controller
async handler"] C --> M["Mongoose model"] M --> DB[("MongoDB")] DB --> M C --> Res["JSON response
+ status code"] V -.->|invalid| E["error middleware
4 args"] C -.->|throws| E E --> Res
๐ Why a database changes everything
An in-memory array is fast to write but forgets on restart, can't be shared between server instances, and offers no querying beyond a linear scan you code by hand. A database gives you durable persistence, concurrent access, indexed queries, and โ through Mongoose โ a schema that validates every write before it lands. You stop writing storage logic and start declaring what your data is; the database and the ODM enforce it for you.
Prerequisites
This is the Week 8 capstone, so it builds directly on the Express API you shipped last weekend plus everything this week taught about databases. Before you start, make sure you're comfortable with:
- Express & REST โ
express.Router(), route params,req.body/req.query, middleware, and the(req, res, next)signature (last week's Task API) - async/await & promises โ every database call returns a promise you'll
await - MongoDB basics โ documents, collections, and
ObjectId, from this week's database lessons - Mongoose fundamentals โ schemas, models, and the basic query methods (
find,findById,create) - Environment variables โ why secrets and connection strings live in
.env, not in code - Custom error classes & centralized error handling โ the
AppErrorpattern from Week 7
You'll need Node.js 18 or newer (node --version) and access to a MongoDB database. Two easy options:
- Local: install MongoDB Community Server and connect to
mongodb://127.0.0.1:27017/blog_api. - Cloud (recommended): create a free MongoDB Atlas cluster and copy its connection string โ no local install required.
โ ๏ธ No authentication this weekend
A production blog would lock its write routes behind login. We're deliberately not building auth here โ that is exactly what Week 9 opens with. This project's job is to master database persistence and relationships; keeping auth out lets you focus every hour on Mongoose. You'll bolt security onto this very API next week.
Required Features Checklist
These are the non-negotiables. Every one is achievable with Express, Mongoose, and express-validator. Tick each off as you go.
โ Must-have features
- โ Real database persistence โ MongoDB via Mongoose 7+, connected with an env-var connection string
- โ Two related resources โ
PostandComment, with every comment referencing its post - โ Schema + model with validation โ required fields, length limits, enums, and defaults enforced by Mongoose
- โ Full CRUD for posts โ create, read one, read all, update, delete on REST routes
- โ Comment CRUD under a post โ list & create via the nested route, update & delete by id
- โ The relationship resolved โ
populate()joins a post to its author-less comment list (and comments back to their post) - โ Pagination, filtering & sorting on
GET /api/posts, with pagination metadata in the response - โ Centralized error handling โ one 4-arg middleware that also translates Mongoose
CastError, duplicate-key, andValidationError - โ Correct status codes โ 200, 201, 204 on success; 400, 404, 422 on failure
- โ async handlers โ every controller wrapped so a rejected promise reaches the error middleware via
next(err)
The Data Model
Two collections, one relationship. A Post is a blog article. A Comment is a reader's response, and it stores the _id of the post it belongs to โ a classic one-to-many link (one post has many comments). In MongoDB you can either embed comments inside the post document or keep them in their own collection and reference the post; we use the referenced approach, because comments are their own resource with their own routes and lifecycle.
๐ก Reference vs embed โ why we reference
Mongoose lets you embed (store comments as an array inside the post) or reference (store comments separately, holding the post's _id). Embed wins when the children are few, always fetched with the parent, and never queried alone. Reference wins here: comments can grow unbounded, you want to page and delete them independently, and they have their own endpoints. The trade-off is that reading a post and its comments takes a join โ which is exactly what populate() does for us.
Project Structure
Same role-based layout as last week, with a config/ folder for the database connection. Each concern gets its own file, so a ten-model API keeps this exact shape โ you're learning the pattern, not just the app.
blog-api/
โโโ package.json
โโโ .env <-- MONGO_URI, PORT (never committed)
โโโ .gitignore <-- ignores node_modules, .env
โโโ src/
โโโ server.js <-- entry point: connect DB, then app.listen()
โโโ app.js <-- builds the Express app (no listen)
โโโ config/
โ โโโ db.js <-- connectDB(): mongoose.connect(...)
โโโ models/
โ โโโ Post.js <-- Post schema + model
โ โโโ Comment.js <-- Comment schema + model (refs Post)
โโโ routes/
โ โโโ postRoutes.js <-- /api/posts (+ mounts comments)
โ โโโ commentRoutes.js<-- /api/posts/:postId/comments
โโโ controllers/
โ โโโ postController.js
โ โโโ commentController.js
โโโ middleware/
โ โโโ validate.js <-- express-validator chains + runValidation
โ โโโ asyncHandler.js <-- forwards async errors to next()
โ โโโ errorHandler.js <-- the one 4-arg error middleware + 404
โโโ errors/
โโโ AppError.js <-- AppError + NotFoundError, ValidationError
๐ก Why split app.js from server.js?
app.js builds and returns the configured Express app but never opens a port. server.js connects to the database first, then imports the app and calls listen(). Keeping them apart means tests can import the app and hit its routes in memory (with supertest) without a live server โ and it forces the healthy habit of not accepting requests until the database is ready.
Stage 1 โ Scaffold & Connect the Database
Start the project and install the runtime dependencies. mongoose is the ODM, dotenv loads your .env file, and express-validator handles input checks.
# Create and enter the project
mkdir blog-api && cd blog-api
npm init -y
# Runtime deps
npm install express mongoose dotenv express-validator
# Dev dep: auto-restart on save
npm install --save-dev nodemon
Add scripts to package.json:
{
"name": "blog-api",
"version": "1.0.0",
"main": "src/server.js",
"type": "commonjs",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
}
}
Create a .env file for your connection string and port. Never commit this file โ add it to .gitignore immediately. The connection string lives here, not in code, so the same app can point at a local database in development and a cloud cluster in production without any edit.
# .env (add to .gitignore!)
PORT=3000
# Local MongoDB:
MONGO_URI=mongodb://127.0.0.1:27017/blog_api
# ...or an Atlas cluster:
# MONGO_URI=mongodb+srv://USER:PASS@cluster0.xxxxx.mongodb.net/blog_api
# .gitignore
node_modules/
.env
Now the connection helper. Modern Mongoose (7+) no longer needs the old useNewUrlParser/useUnifiedTopology flags โ they were removed and are the default. Keep the connect logic in one async function so server.js can await it before listening.
// src/config/db.js
const mongoose = require('mongoose');
// Connect once at startup. Throws on failure so server.js can exit.
async function connectDB() {
const uri = process.env.MONGO_URI;
if (!uri) throw new Error('MONGO_URI is not set โ check your .env file');
const conn = await mongoose.connect(uri); // Mongoose 7+: no extra options needed
console.log(`MongoDB connected: ${conn.connection.host}`);
return conn;
}
module.exports = connectDB;
Build the app. As before, app.js configures middleware and routes but never listens.
// src/app.js โ builds the app but does NOT start it
const express = require('express');
const postRoutes = require('./routes/postRoutes');
const { notFoundHandler, errorHandler } = require('./middleware/errorHandler');
function createApp() {
const app = express();
app.use(express.json()); // parse JSON bodies โ req.body
// Health check
app.get('/', (req, res) => {
res.json({ name: 'Blog API', version: '1.0.0', resource: '/api/posts' });
});
// Feature routes (comments are mounted inside postRoutes)
app.use('/api/posts', postRoutes);
// 404 for anything unmatched, then the ONE error handler โ order matters
app.use(notFoundHandler); // after all routes
app.use(errorHandler); // dead last, 4 args
return app;
}
module.exports = createApp;
And the entry point. Notice the sequence: load env โ connect to the database โ only then listen. If the database is unreachable, the process logs the error and exits rather than accepting requests it can't serve.
// src/server.js โ the only file that connects + opens a port
require('dotenv').config();
const createApp = require('./app');
const connectDB = require('./config/db');
const PORT = process.env.PORT || 3000;
async function start() {
try {
await connectDB(); // wait for the DB before serving traffic
const app = createApp();
app.listen(PORT, () => {
console.log(`Blog API listening on http://localhost:${PORT}`);
});
} catch (err) {
console.error('Failed to start:', err.message);
process.exit(1);
}
}
start();
โ ๏ธ Connect before you listen
A common beginner bug is calling app.listen() and mongoose.connect() side by side and hoping the DB is ready by the time the first request lands. await the connection first. If Mongo is down, you want a clean startup failure โ not a server that returns 500s until someone notices.
Stage 2 โ Define the Models
A Mongoose schema declares the shape of a document โ its fields, their types, and the rules each must satisfy. The model is the class you actually query. Validation lives right in the schema, so every create and save is checked before it touches the database; you never repeat those rules in a controller.
The Post model
// src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'A post must have a title'],
trim: true,
maxlength: [120, 'Title cannot exceed 120 characters'],
},
body: {
type: String,
required: [true, 'A post must have a body'],
trim: true,
},
author: {
type: String,
required: [true, 'A post must have an author'],
trim: true,
maxlength: [60, 'Author name cannot exceed 60 characters'],
},
status: {
type: String,
enum: {
values: ['draft', 'published'],
message: 'status must be either draft or published',
},
default: 'draft',
},
tags: {
type: [String],
default: [],
},
},
{
timestamps: true, // auto createdAt + updatedAt
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// A virtual "comments" field: not stored, resolved on demand via populate().
postSchema.virtual('comments', {
ref: 'Comment',
localField: '_id',
foreignField: 'post',
});
module.exports = mongoose.model('Post', postSchema);
The Comment model
The relationship lives here: post is an ObjectId that references a Post. That ref is what lets populate() swap the id for the real post document later.
// src/models/Comment.js
const mongoose = require('mongoose');
const commentSchema = new mongoose.Schema(
{
body: {
type: String,
required: [true, 'A comment must have a body'],
trim: true,
maxlength: [500, 'Comment cannot exceed 500 characters'],
},
author: {
type: String,
required: [true, 'A comment must have an author'],
trim: true,
maxlength: [60, 'Author name cannot exceed 60 characters'],
},
// THE relationship: which post this comment belongs to.
post: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Post',
required: true,
},
},
{ timestamps: true }
);
module.exports = mongoose.model('Comment', commentSchema);
๐ What the schema buys you
Every rule above โ required, maxlength, enum, trim, default, timestamps โ runs automatically on write. Try to save a post with no title and Mongoose rejects it with a ValidationError before a single byte reaches Mongo. That's defense in depth: express-validator catches bad input at the edge for friendly messages, and the schema is the last line that guarantees nothing malformed ever persists. Declare the rule once, in the schema, and it's enforced everywhere the model is used.
Stage 3 โ Post CRUD Routes & Controllers
First the route table mapping each URL + verb to a controller, with validation middleware slotted in front of the writes.
// src/routes/postRoutes.js
const express = require('express');
const controller = require('../controllers/postController');
const commentRoutes = require('./commentRoutes');
const { validateCreatePost, validateUpdatePost, runValidation } = require('../middleware/validate');
const router = express.Router();
// Nested comments: /api/posts/:postId/comments
router.use('/:postId/comments', commentRoutes);
router
.route('/')
.get(controller.list) // GET /api/posts
.post(validateCreatePost, runValidation, controller.create); // POST /api/posts
router
.route('/:id')
.get(controller.getOne) // GET /api/posts/:id
.put(validateUpdatePost, runValidation, controller.update) // PUT /api/posts/:id
.delete(controller.remove); // DELETE /api/posts/:id
module.exports = router;
โ The REST route map at a glance
| Verb + Path | Action | Success code |
|---|---|---|
GET /api/posts | List (paginated/filtered/sorted) | 200 OK |
POST /api/posts | Create one post | 201 Created |
GET /api/posts/:id | Read one post (+ comments) | 200 OK |
PUT /api/posts/:id | Update a post | 200 OK |
DELETE /api/posts/:id | Delete a post (+ its comments) | 204 No Content |
The asyncHandler wrapper
Every controller is async and awaits the database. A thrown error inside an async function does not automatically reach Express's error middleware, so a tiny wrapper catches any rejected promise and forwards it to next. Write it once, wrap every handler โ no scattered try/catch.
// src/middleware/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
The post controllers
Each controller reads what it needs off req, calls the Mongoose model, and responds with the correct status. There's no try/catch: asyncHandler forwards rejections, and a missing document throws a NotFoundError that sails to the error middleware. (The list handler is fleshed out in Stage 5.)
// src/controllers/postController.js
const Post = require('../models/Post');
const Comment = require('../models/Comment');
const asyncHandler = require('../middleware/asyncHandler');
const { NotFoundError } = require('../errors/AppError');
const { buildListQuery } = require('../middleware/query'); // built in Stage 5
// GET /api/posts โ list with pagination, filter, sort (Stage 5)
exports.list = asyncHandler(async (req, res) => {
const { filter, sort, page, limit, skip } = buildListQuery(req.query);
const [data, total] = await Promise.all([
Post.find(filter).sort(sort).skip(skip).limit(limit),
Post.countDocuments(filter),
]);
res.status(200).json({
data,
pagination: {
page, limit, total,
totalPages: Math.max(1, Math.ceil(total / limit)),
hasNext: page * limit < total,
},
});
});
// GET /api/posts/:id โ read one, with its comments joined in
exports.getOne = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id).populate({
path: 'comments',
select: 'body author createdAt',
options: { sort: { createdAt: -1 } },
});
if (!post) throw new NotFoundError(`Post ${req.params.id} not found`);
res.status(200).json({ data: post });
});
// POST /api/posts โ create
exports.create = asyncHandler(async (req, res) => {
const post = await Post.create(req.body); // schema validates before saving
res.status(201).json({ data: post }); // 201 Created for a new resource
});
// PUT /api/posts/:id โ update (runValidators re-checks the schema on update)
exports.update = asyncHandler(async (req, res) => {
const post = await Post.findByIdAndUpdate(req.params.id, req.body, {
new: true, // return the updated document, not the old one
runValidators: true, // apply schema validation to the update too
});
if (!post) throw new NotFoundError(`Post ${req.params.id} not found`);
res.status(200).json({ data: post });
});
// DELETE /api/posts/:id โ delete the post AND its orphaned comments
exports.remove = asyncHandler(async (req, res) => {
const post = await Post.findByIdAndDelete(req.params.id);
if (!post) throw new NotFoundError(`Post ${req.params.id} not found`);
await Comment.deleteMany({ post: post._id }); // clean up the children
res.status(204).send(); // 204 No Content
});
๐ก Deleting a post must not orphan its comments
Because comments reference the post by _id, deleting a post would leave its comments pointing at nothing. Unlike a SQL database with ON DELETE CASCADE, MongoDB won't clean up for you โ so the remove controller explicitly calls Comment.deleteMany({ post: post._id }). Owning the relationship means owning its cleanup.
Stage 4 โ Comments & the Relationship
Comments live under a post: GET and POST at /api/posts/:postId/comments, with update and delete by the comment's own id. Because this router is mounted inside postRoutes, it needs mergeParams: true to see the parent's :postId.
// src/routes/commentRoutes.js
const express = require('express');
const controller = require('../controllers/commentController');
const { validateCreateComment, runValidation } = require('../middleware/validate');
// mergeParams lets this nested router read :postId from the parent route
const router = express.Router({ mergeParams: true });
router
.route('/')
.get(controller.listForPost) // GET /api/posts/:postId/comments
.post(validateCreateComment, runValidation, controller.create); // POST /api/posts/:postId/comments
router
.route('/:commentId')
.put(validateCreateComment, runValidation, controller.update) // PUT .../comments/:commentId
.delete(controller.remove); // DELETE .../comments/:commentId
module.exports = router;
The controllers set the post reference from the URL, and confirm the parent post exists before creating a comment โ otherwise you'd store comments pointing at a phantom post.
// src/controllers/commentController.js
const Comment = require('../models/Comment');
const Post = require('../models/Post');
const asyncHandler = require('../middleware/asyncHandler');
const { NotFoundError } = require('../errors/AppError');
// GET /api/posts/:postId/comments โ all comments for one post
exports.listForPost = asyncHandler(async (req, res) => {
const comments = await Comment.find({ post: req.params.postId })
.sort('-createdAt');
res.status(200).json({ data: comments });
});
// POST /api/posts/:postId/comments โ add a comment to a post
exports.create = asyncHandler(async (req, res) => {
// Confirm the parent post exists first โ no orphan comments.
const post = await Post.findById(req.params.postId);
if (!post) throw new NotFoundError(`Post ${req.params.postId} not found`);
const comment = await Comment.create({
body: req.body.body,
author: req.body.author,
post: req.params.postId, // the relationship, set from the URL
});
res.status(201).json({ data: comment });
});
// PUT /api/posts/:postId/comments/:commentId โ edit a comment
exports.update = asyncHandler(async (req, res) => {
const comment = await Comment.findByIdAndUpdate(
req.params.commentId,
{ body: req.body.body, author: req.body.author },
{ new: true, runValidators: true }
);
if (!comment) throw new NotFoundError(`Comment ${req.params.commentId} not found`);
res.status(200).json({ data: comment });
});
// DELETE /api/posts/:postId/comments/:commentId
exports.remove = asyncHandler(async (req, res) => {
const comment = await Comment.findByIdAndDelete(req.params.commentId);
if (!comment) throw new NotFoundError(`Comment ${req.params.commentId} not found`);
res.status(204).send();
});
๐ populate(): the join that resolves the relationship
A comment stores only its post's _id โ a raw reference. When getOne reads a post, the comments virtual plus .populate('comments') runs a second query and stitches the matching comment documents onto the post. You can populate in the other direction too โ Comment.find().populate('post', 'title') swaps each comment's post id for the post's title. That is Mongoose doing, in one call, what a SQL JOIN does across tables: turning a stored key into the real related data.
// Populate the other direction: each comment carries its post's title
const comments = await Comment.find()
.populate('post', 'title status') // replace the id with selected post fields
.sort('-createdAt');
Validation rules for both resources
express-validator checks input at the edge for friendly messages, complementing the schema's guarantees. One runValidation middleware turns any collected errors into your custom ValidationError, so they flow through the same handler as everything else.
// src/middleware/validate.js
const { body, validationResult } = require('express-validator');
const { ValidationError } = require('../errors/AppError');
const validateCreatePost = [
body('title').trim().notEmpty().withMessage('title is required')
.isLength({ max: 120 }).withMessage('title must be 120 characters or fewer'),
body('body').trim().notEmpty().withMessage('body is required'),
body('author').trim().notEmpty().withMessage('author is required'),
body('status').optional().isIn(['draft', 'published'])
.withMessage('status must be draft or published'),
body('tags').optional().isArray().withMessage('tags must be an array'),
];
// Update: same fields, all optional (partial updates allowed)
const validateUpdatePost = [
body('title').optional().trim().notEmpty().withMessage('title cannot be empty')
.isLength({ max: 120 }).withMessage('title must be 120 characters or fewer'),
body('body').optional().trim().notEmpty().withMessage('body cannot be empty'),
body('author').optional().trim().notEmpty().withMessage('author cannot be empty'),
body('status').optional().isIn(['draft', 'published'])
.withMessage('status must be draft or published'),
];
const validateCreateComment = [
body('body').trim().notEmpty().withMessage('body is required')
.isLength({ max: 500 }).withMessage('comment must be 500 characters or fewer'),
body('author').trim().notEmpty().withMessage('author is required'),
];
// Shared: collect errors โ one consistent ValidationError
function runValidation(req, res, next) {
const result = validationResult(req);
if (result.isEmpty()) return next();
const details = result.array().map((e) => ({ field: e.path, message: e.msg }));
next(new ValidationError('Validation failed', details));
}
module.exports = {
validateCreatePost, validateUpdatePost, validateCreateComment, runValidation,
};
Stage 5 โ Pagination, Filter & Sort
A list endpoint that returns every post collapses the moment there are thousands of them. Real APIs let the client ask for a slice: filter to narrow, sort to order, paginate to take one page. With a database you push all three into the query โ Mongoose translates .skip(), .limit(), and .sort() into an efficient MongoDB operation, so the database does the work, not your Node process.
Keep the query-building in one small, testable helper. It reads req.query like ?status=published&tag=js&sort=-createdAt&page=2&limit=10 and returns the pieces the controller needs โ with every input clamped and whitelisted.
// src/middleware/query.js
const ALLOWED_SORT = ['createdAt', 'updatedAt', 'title', 'status'];
// Turn req.query into a safe { filter, sort, page, limit, skip } bundle.
function buildListQuery(query) {
// --- FILTER: only allow known fields, never trust raw query as a filter ---
const filter = {};
if (query.status) filter.status = query.status; // 'draft' | 'published'
if (query.author) filter.author = query.author;
if (query.tag) filter.tags = query.tag; // matches any post with that tag
// --- SORT: "-createdAt" = descending; whitelist the field ---
let sort = '-createdAt'; // sensible default: newest first
if (query.sort) {
const desc = query.sort.startsWith('-');
const key = desc ? query.sort.slice(1) : query.sort;
if (ALLOWED_SORT.includes(key)) sort = (desc ? '-' : '') + key;
}
// --- PAGINATE: clamp to safe numbers ---
const page = Math.max(1, parseInt(query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(query.limit, 10) || 20));
const skip = (page - 1) * limit;
return { filter, sort, page, limit, skip };
}
module.exports = { buildListQuery };
The list controller from Stage 3 already uses this. Running the find and the countDocuments together with Promise.all means one round-trip's worth of latency, and the response carries both the page and the metadata a client needs to build "next / previous" controls:
{
"data": [
{ "_id": "66b0โฆ", "title": "Hello, Mongo", "author": "Ray", "status": "published", "tags": ["js"], "createdAt": "โฆ", "updatedAt": "โฆ" }
],
"pagination": { "page": 2, "limit": 10, "total": 47, "totalPages": 5, "hasNext": true }
}
โ ๏ธ Whitelist filter and sort fields
Query parameters are strings typed by strangers. If you blindly spread req.query into Post.find(req.query), a client can filter (or, with operators, probe) on any field โ a real injection risk. The helper above only ever copies known keys into the filter and only sorts by a whitelisted field, falling back to a safe default. Clamp the numbers, whitelist the fields, and never let raw query input drive a database read unfiltered.
Stage 6 โ Centralized Error Handling
Here's the payoff for the structure. A small family of custom error classes each carry an HTTP status, and one error middleware turns any of them โ plus Mongoose's own errors โ into a clean JSON response. Throw anywhere; it lands here.
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class BadRequestError extends AppError {
constructor(message = 'Bad request') { super(message, 400); }
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') { super(message, 404); }
}
class ValidationError extends AppError {
constructor(message = 'Validation failed', details = []) {
super(message, 422);
this.details = details; // per-field error list
}
}
module.exports = { AppError, BadRequestError, NotFoundError, ValidationError };
The middleware is mounted last in app.js, and its four-argument signature (err, req, res, next) is how Express recognizes it. Beyond your own AppErrors, it translates the three Mongoose errors you'll actually hit โ a malformed id (CastError), a duplicate unique field (code 11000), and a schema ValidationError โ into the right status codes.
// src/middleware/errorHandler.js
const { AppError, NotFoundError } = require('../errors/AppError');
// Any request that matched no route โ a 404 error.
function notFoundHandler(req, res, next) {
next(new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`));
}
// THE central error handler. Must take 4 args and be mounted last.
function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
let details;
// --- Translate common Mongoose errors into clean HTTP responses ---
if (err.name === 'CastError') {
statusCode = 400; // bad ObjectId in the URL
message = `Invalid ${err.path}: ${err.value}`;
} else if (err.code === 11000) {
statusCode = 409; // duplicate unique key
message = `Duplicate value for ${Object.keys(err.keyValue).join(', ')}`;
} else if (err.name === 'ValidationError' && err.errors) {
statusCode = 422; // schema validation failed
message = 'Validation failed';
details = Object.values(err.errors).map((e) => ({ field: e.path, message: e.message }));
} else if (err instanceof AppError && err.details) {
details = err.details; // our own ValidationError
}
// Unknown 5xx errors get a safe generic message.
const isKnown = err instanceof AppError || statusCode < 500;
const body = {
error: {
message: isKnown ? message : 'Internal Server Error',
status: statusCode,
},
};
if (details) body.error.details = details;
if (process.env.NODE_ENV !== 'production') body.error.stack = err.stack;
if (statusCode >= 500) console.error(err); // only true surprises worth shouting about
res.status(statusCode).json(body);
}
module.exports = { notFoundHandler, errorHandler };
๐ One click through the whole error path
Client sends GET /api/posts/not-a-real-id โ getOne calls Post.findById('not-a-real-id') โ Mongoose can't cast that string to an ObjectId and throws a CastError โ the rejected promise is caught by asyncHandler, which calls next(err) โ Express jumps to the 4-arg errorHandler โ it sees err.name === 'CastError', sets 400, and responds {"error":{"message":"Invalid _id: not-a-real-id","status":400}}. You wrote the formatting once, and it covers your own not-found errors, express-validator failures, and every Mongoose error alike.
Stage 7 โ Test With curl
Fire up the server with npm run dev (make sure MongoDB is running or your Atlas string is set), then drive every endpoint from a second terminal. Watch the status code, not just the body โ that's the real contract. Add -i to print the status line and headers.
# Create a post โ expect 201 Created (copy the _id from the response)
curl -i -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"Hello, Mongo","body":"My first persisted post.","author":"Ray","status":"published","tags":["js","mongodb"]}'
# List posts, filtered + sorted + paginated โ expect 200 OK
curl "http://localhost:3000/api/posts?status=published&sort=-createdAt&page=1&limit=10"
# Read one post WITH its comments populated โ 200 or 404
curl http://localhost:3000/api/posts/PASTE_POST_ID
# Add a comment to that post โ expect 201 Created
curl -i -X POST http://localhost:3000/api/posts/PASTE_POST_ID/comments \
-H "Content-Type: application/json" \
-d '{"body":"Great first post!","author":"Ada"}'
# Update the post โ expect 200 OK
curl -X PUT http://localhost:3000/api/posts/PASTE_POST_ID \
-H "Content-Type: application/json" \
-d '{"status":"draft"}'
# Delete the post (also removes its comments) โ expect 204 No Content
curl -i -X DELETE http://localhost:3000/api/posts/PASTE_POST_ID
Now prove the unhappy paths, where beginner APIs fall apart:
# Missing title โ expect 422 with a per-field details array
curl -i -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{"body":"no title here","author":"Ray"}'
# Malformed id โ expect 400 (Mongoose CastError, translated)
curl -i http://localhost:3000/api/posts/not-a-real-id
# Well-formed but unknown id โ expect 404 Not Found
curl -i http://localhost:3000/api/posts/000000000000000000000000
# Bad route โ expect 404 from the notFoundHandler
curl -i http://localhost:3000/api/nope
The 422 response should have this stable shape your future front end can rely on:
{
"error": {
"message": "Validation failed",
"status": 422,
"details": [
{ "field": "title", "message": "title is required" }
]
}
}
โ Verify persistence for real
The whole point of a database is that data survives. Create a couple of posts, then stop the server (Ctrl-C) and start it again with npm run dev. Hit GET /api/posts โ your posts are still there. Last week's in-memory store would have returned an empty list. That difference is this entire project in one experiment.
Stretch Goals
Finished the required build with time to spare? Level it up โ pick whichever excites you; none are needed to pass the rubric.
- ๐ Full-text search โ add a text index (
postSchema.index({ title: 'text', body: 'text' })) and a?q=query param that runsPost.find({ $text: { $search: q } }) - ๐ Unique slugs โ auto-generate a URL slug from the title in a
pre('save')hook, mark itunique, and watch the error handler turn a collision into a clean409 - ๐ Comment pagination โ apply the same
buildListQuerypattern to the nested comments list - ๐ฑ A seed script โ write
src/seed.jsthat connects, wipes the collections, and inserts sample posts + comments (ties straight back to this week's data-seeding lesson) - ๐งช Automated tests โ install
jest,supertest, andmongodb-memory-server, importcreateApp(), and assert on status codes and bodies against an in-memory Mongo - ๐ฉน PATCH support โ add
PATCH /api/posts/:idfor partial updates alongside the full-replacePUT - โก Indexes โ add an index on
{ status: 1, createdAt: -1 }and use.explain()to see the query planner use it
Seed-script starter
A seed script is a tiny standalone program that connects, resets, and inserts โ perfect for demos and tests:
// src/seed.js โ run with: node src/seed.js
require('dotenv').config();
const mongoose = require('mongoose');
const Post = require('./models/Post');
const Comment = require('./models/Comment');
async function seed() {
await mongoose.connect(process.env.MONGO_URI);
await Promise.all([Post.deleteMany({}), Comment.deleteMany({})]);
const post = await Post.create({
title: 'Seeded Post', body: 'Inserted by the seed script.',
author: 'Seeder', status: 'published', tags: ['demo'],
});
await Comment.create({ body: 'First!', author: 'Bot', post: post._id });
console.log('Seeded 1 post + 1 comment');
await mongoose.disconnect();
}
seed().catch((err) => { console.error(err); process.exit(1); });
Self-Check Rubric
Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns โ the stretch column is bonus.
| Area | Meets expectations (required) | Exceeds (stretch) |
|---|---|---|
| Database connection | Mongoose 7+ connected via an env-var MONGO_URI; server awaits the DB before it listens |
Graceful reconnect handling; connection events logged |
| Models & schema | Post & Comment schemas with required fields, length limits, enum, defaults & timestamps | Text index, unique slug via pre('save'), compound indexes |
| Relationship | Comment references Post by ObjectId; populate() resolves it; deleting a post removes its comments |
Bidirectional populate; comment counts as a virtual |
| CRUD & routes | Full CRUD for posts + comment create/list/update/delete on nested REST routes | PATCH for partial updates; comment pagination |
| Status codes | 201 on create, 204 on delete, 200 on read/update; 400/404/422 on failure | 409 on duplicate key handled cleanly |
| List endpoint | Pagination, filter & sort via skip/limit/sort, with metadata & total count |
Clamped & whitelisted inputs; indexed sort field |
| Error handling | One 4-arg middleware; custom AppError classes; Mongoose CastError/Validation/duplicate translated; asyncHandler everywhere |
Stack hidden in production; 5xx logged; automated tests green |
๐งช Final testing checklist
- โ
POSTa valid post returns 201 and the created post with a real_id - โ
POSTwith notitlereturns 422 and adetailsarray namingtitle - โ
GET /api/postsreturns{ data, pagination };?limit=2&page=2returns the right slice - โ
?status=published&sort=-createdAtfilters and orders correctly - โ
GET /api/posts/:idincludes a populatedcommentsarray - โ A malformed id returns 400; a well-formed unknown id returns 404 (never a 500 crash)
- โ Deleting a post also removes its comments (verify with the comments endpoint)
- โ Data survives a server restart โ the real proof of persistence
Summary
๐ What You Built
- An Express API backed by a real MongoDB database, connected through Mongoose 7+ with an environment-variable connection string and an
asyncconnect-before-listen startup - Two related resources โ Posts and Comments โ with a one-to-many relationship modeled by an
ObjectIdreference and resolved withpopulate() - Schema-level validation: required fields, length limits, enums, defaults, and timestamps enforced on every write
- Full CRUD for posts and comment management under a nested route, each returning the precise status code its outcome deserves
- Pagination, filtering, and sorting pushed into the database query with
skip/limit/sort, clamped and whitelisted, with metadata in the response - A single 4-argument error middleware that turns your custom errors and Mongoose's CastError, duplicate-key, and ValidationError into one clean response shape
This project is proof that Week 8 stuck. You crossed from data that vanished on restart to data that persists, and you did it the way professionals do: declare your schema, model your relationships, let the database do the querying, and funnel every error to one place. The moves you practiced โ connect before you listen, reference and populate, whitelist your query inputs, translate database errors โ are the everyday grammar of production backends with real storage behind them.
๐ Additional Resources
- Mongoose โ Schemas guide
- Mongoose โ Populate (relationships)
- Mongoose โ Validation
- MongoDB โ Query documents
- Express โ Error handling
- MDN โ HTTP response status codes
๐ What's Next?
Your API persists data beautifully โ but right now anyone can create, edit, or delete any post or comment. That's fine for a weekend build, unacceptable for the real world. Week 9 opens exactly there: the next lesson, Authentication vs Authorization, draws the crucial line between proving who you are and controlling what you're allowed to do โ the foundation you'll build on to lock down this very API with logins, tokens, and permissions.
๐ You finished Week 8!
You shipped a database-backed API with real relationships, validation, and centralized errors. Push it to GitHub, add it to your portfolio โ your data is durable now.