Blog API with Pagination
Difficulty: Intermediate · Builds on: routing, Joi validation, MongoDB and Mongoose, query parameters
You are tasked with building the backend for a multi-author blog. The interesting part is not the CRUD — it is the list endpoint. Real list endpoints never return "everything": they paginate, filter, sort, and search, and they do it safely so a client cannot sort by arbitrary fields or smuggle MongoDB operators through the query string.
What You'll Practice
- Designing a Mongoose schema with the right indexes for how it will be queried
- Pagination with
pageandlimit, plus ametablock the frontend can render - Filtering by
authorandtag, sorting through an allowlist, and case-insensitive search - Validating body and query parameters with Joi
- Returning
400for malformed ids and404for missing documents — never a crash
Requirements
- Full CRUD for posts: create, list, get one, partial update, delete
GET /postssupportspageandlimitwith defaults (page 1, limit 10) and a hard cap onlimit- Filtering by
author(exact match) andtag(posts containing that tag) sortonly accepts values from an allowlist — anything else is a400searchmatches the title case-insensitively, with regex special characters escaped- Tags are normalized to lowercase so
Expressandexpressare the same tag - A malformed ObjectId returns
400; a well-formed but unknown id returns404 - The fields you filter and sort by are covered by indexes
API Contract
| Method | Endpoint | Request | Success Response |
|---|---|---|---|
| POST | /posts | Body: title, content, author, tags | 201 — created post |
| GET | /posts | Query: page, limit, author, tag, sort, search | 200 — paginated array plus meta |
| GET | /posts/:id | — | 200 — single post |
| PATCH | /posts/:id | Body: any of title, content, tags | 200 — updated post |
| DELETE | /posts/:id | — | 200 — confirmation message |
The list response shape:
{
"status": 200,
"response": "OK",
"message": "Posts fetched successfully",
"data": [
{
"_id": "66a1c7b0e9b5a8a2b4b0b5a1",
"title": "Middleware Order Matters",
"content": "The order you register middleware in is the order it runs...",
"author": "Ayesha Ashiq",
"tags": ["express", "middleware"],
"createdAt": "2026-07-01T10:00:00.000Z",
"updatedAt": "2026-07-01T10:00:00.000Z"
}
],
"meta": {
"page": 1,
"limit": 5,
"total_items": 12,
"total_pages": 3
}
}
Edge Cases to Handle
| Scenario | Expected result |
|---|---|
GET /posts/abc (malformed id) | 400 — "Invalid post id" |
GET /posts/66a1... (valid id, no document) | 404 — "Post not found" |
?limit=5000 | 400 — Joi rejects anything above the cap |
?sort=password or ?sort=__proto__ | 400 — not in the sort allowlist |
?author[$ne]=x (operator injection) | 400 — Joi expects a string, not an object |
?search=(react (regex special characters) | Treated as literal text, not a broken regex |
PATCH /posts/:id with an empty body | 400 — at least one field is required |
| Page beyond the last one | 200 — empty data array with correct meta |
Build Guide
Step 1 — Project setup
Nothing new here: npm install express@4 mongoose joi dotenv, "type": "module" in package.json, a .env with PORT and MONGO_URI, an express.json() app that mounts a /posts router, a JSON 404 catch-all, a central error handler, and mongoose.connect before listen — the same skeleton as the Task Manager.
Step 2 — The model and its indexes
import mongoose from "mongoose";
const postSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true, maxlength: 200 },
content: { type: String, required: true },
author: { type: String, required: true, trim: true, maxlength: 60 },
tags: {
type: [String],
default: [],
set: (tags) => tags.map((tag) => tag.trim().toLowerCase()),
},
},
{ timestamps: true },
);
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
postSchema.index({ createdAt: -1 });
postSchema.index({ title: 1 });
export default mongoose.model("Post", postSchema);
Why these indexes:
{ author: 1, createdAt: -1 }is a compound index that serves the most common query in one pass: "this author's posts, newest first". MongoDB walks the index in order, so it can filter and sort without loading and re-sorting every matching document in memory.{ tags: 1 }on an array field becomes a multikey index — MongoDB indexes each tag separately, sotags: "express"is an index lookup instead of a collection scan.{ createdAt: -1 }covers the default listing with no author filter, and{ title: 1 }covers both title sort directions — MongoDB can walk a single-field index backwards.
Once you have data, confirm the indexes are actually used with explain() in the mongo shell — look for IXSCAN instead of COLLSCAN in the winning plan.
Step 3 — The Joi query schema
The query schema is the security boundary of the list endpoint. Every value is typed, capped, and — for sort — allowlisted. Because Joi insists on strings and numbers, a query like ?author[$ne]=x (which Express parses into an object) fails validation before it can reach MongoDB as an operator.
import Joi from "joi";
export const listPostsSchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(50).default(10),
author: Joi.string().trim().min(1).max(60),
tag: Joi.string().trim().min(1).max(30),
sort: Joi.string().valid("createdAt", "-createdAt", "title", "-title").default("-createdAt"),
search: Joi.string().trim().min(1).max(100),
});
The sort allowlist matters for two reasons: it stops clients from sorting by fields you never intended to expose, and it guarantees every allowed sort is backed by an index.
The two body schemas are yours to write in the same file: a create schema requiring title, content, and author with sane length limits and lowercased tags, and an update schema allowing any of title, content, tags — with .min(1) at the object level so an empty PATCH body is a 400 with a clear message.
Step 4 — A reusable validation middleware
Write a validate(schema, property = "body") factory that returns Express middleware:
- Run
schema.validate(req[property])withstripUnknown: trueso unrecognized fields are silently dropped. - On failure, respond
400with the first validation message — the request never reaches a controller. - On success, write the validated
valueback toreq[property], so controllers only ever see clean, typed, defaulted data —req.query.pageis already a number,limitis already capped.
Step 5 — Controllers
The list controller is the heart of the exercise. GET /posts should follow this exact sequence:
- Read the validated query —
page,limit,sort, and any ofauthor,tag,search, already defaulted and capped by the middleware. - Build the filter from allowlisted params only —
authoras an exact match,taglowercased to match the schema's setter. Never spreadreq.queryinto the filter. - Escape the search term before it becomes a regex — replace every regex metacharacter with its backslash-escaped form, then match
titlewith a case-insensitiveRegExp. Without this,?search=(reactthrows an unterminated-group error and?search=.*matches everything. (Careful when testing with+: in a query string a raw+decodes to a space — send%2Bto actually test a plus sign.) - Run the page query and the count in parallel —
Promise.alloverfind(filter).sort(sort).skip((page - 1) * limit).limit(limit)andcountDocuments(filter). - Return
dataplusmeta—page,limit,total_items, andtotal_pagesasMath.ceil(total / limit). A page past the end is not an error: it returns an empty array with the same meta.
The id-based endpoints (get one, patch, delete) share one guard: check mongoose.isValidObjectId(req.params.id) first and return 400 Invalid post id; only then query and return 404 Post not found when nothing matches. The distinction matters — a malformed id can never exist (a client bug, so 400), while a well-formed but unknown id is simply absent (404) — and the guard turns what would otherwise be a Mongoose CastError in your error handler into a clean, deliberate response. For the update, use findByIdAndUpdate with new: true and runValidators: true.
Create is a plain Post.create(req.body) returning 201 — the body was already validated and stripped.
Step 6 — Routes and app
Wire the router so validation runs before each controller: validate(createPostSchema) on POST /, validate(listPostsSchema, "query") on GET /, validate(updatePostSchema) on PATCH /:id; the two id-only routes need no schema. Mount the router at /posts in app.js, with the 404 catch-all and error handler from Step 1 registered after it.
Test It with curl
curl -s -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title":"Middleware Order Matters","content":"The order you register middleware in is the order it runs.","author":"Ayesha Ashiq","tags":["Express","Middleware"]}'
curl -s -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title":"Indexing Mongo Queries","content":"Compound indexes serve filter plus sort in one pass.","author":"Ibrahim Ashiq","tags":["mongodb","performance"]}'
curl -s -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title":"Pagination Done Right","content":"Always return meta with the data.","author":"Ayesha Ashiq","tags":["express","pagination"]}'
curl -s "http://localhost:3000/posts?author=Ayesha%20Ashiq&tag=express&page=1&limit=5"
curl -s "http://localhost:3000/posts?sort=title"
curl -s "http://localhost:3000/posts?search=pagination"
curl -s "http://localhost:3000/posts/not-a-real-id"
curl -s "http://localhost:3000/posts?sort=secretField"
curl -s "http://localhost:3000/posts?limit=5000"
curl -s -X PATCH http://localhost:3000/posts/66a1c7b0e9b5a8a2b4b0b5a1 \
-H "Content-Type: application/json" -d '{}'
The first should be 400 Invalid post id, the next two 400 validation errors, and the last 400 At least one field is required to update — validation runs before the id lookup, so an empty body is rejected even for ids that don't exist.
Stretch Goals
- Ownership — plug in the auth from Problem 3, make
authora user reference, and let only the author update or delete a post - Cursor pagination — replace
skipwith acreatedAt-based cursor and compare performance on a collection with 100k documents - Full-text search — swap the regex search for a MongoDB text index across
titleandcontentwith relevance scoring - Comments subresource —
POST /posts/:id/commentsandGET /posts/:id/comments, deciding deliberately between embedding and referencing