Skip to main content

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 page and limit, plus a meta block the frontend can render
  • Filtering by author and tag, sorting through an allowlist, and case-insensitive search
  • Validating body and query parameters with Joi
  • Returning 400 for malformed ids and 404 for missing documents — never a crash

Requirements

  • Full CRUD for posts: create, list, get one, partial update, delete
  • GET /posts supports page and limit with defaults (page 1, limit 10) and a hard cap on limit
  • Filtering by author (exact match) and tag (posts containing that tag)
  • sort only accepts values from an allowlist — anything else is a 400
  • search matches the title case-insensitively, with regex special characters escaped
  • Tags are normalized to lowercase so Express and express are the same tag
  • A malformed ObjectId returns 400; a well-formed but unknown id returns 404
  • The fields you filter and sort by are covered by indexes

API Contract

MethodEndpointRequestSuccess Response
POST/postsBody: title, content, author, tags201 — created post
GET/postsQuery: page, limit, author, tag, sort, search200 — paginated array plus meta
GET/posts/:id200 — single post
PATCH/posts/:idBody: any of title, content, tags200 — updated post
DELETE/posts/:id200 — confirmation message

The list response shape:

Response of GET /posts?author=Ayesha%20Ashiq&tag=express&page=1&limit=5
{
"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

ScenarioExpected result
GET /posts/abc (malformed id)400 — "Invalid post id"
GET /posts/66a1... (valid id, no document)404 — "Post not found"
?limit=5000400 — 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 body400 — at least one field is required
Page beyond the last one200 — 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

models/post.js
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, so tags: "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.

validations/post.validation.js
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:

  1. Run schema.validate(req[property]) with stripUnknown: true so unrecognized fields are silently dropped.
  2. On failure, respond 400 with the first validation message — the request never reaches a controller.
  3. On success, write the validated value back to req[property], so controllers only ever see clean, typed, defaulted data — req.query.page is already a number, limit is already capped.

Step 5 — Controllers

The list controller is the heart of the exercise. GET /posts should follow this exact sequence:

  1. Read the validated querypage, limit, sort, and any of author, tag, search, already defaulted and capped by the middleware.
  2. Build the filter from allowlisted params onlyauthor as an exact match, tag lowercased to match the schema's setter. Never spread req.query into the filter.
  3. Escape the search term before it becomes a regex — replace every regex metacharacter with its backslash-escaped form, then match title with a case-insensitive RegExp. Without this, ?search=(react throws an unterminated-group error and ?search=.* matches everything. (Careful when testing with +: in a query string a raw + decodes to a space — send %2B to actually test a plus sign.)
  4. Run the page query and the count in parallelPromise.all over find(filter).sort(sort).skip((page - 1) * limit).limit(limit) and countDocuments(filter).
  5. Return data plus metapage, limit, total_items, and total_pages as Math.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

Create a few posts
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"]}'
Filter, paginate, sort, and search
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"
Verify the edge cases
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

  1. Ownership — plug in the auth from Problem 3, make author a user reference, and let only the author update or delete a post
  2. Cursor pagination — replace skip with a createdAt-based cursor and compare performance on a collection with 100k documents
  3. Full-text search — swap the regex search for a MongoDB text index across title and content with relevance scoring
  4. Comments subresourcePOST /posts/:id/comments and GET /posts/:id/comments, deciding deliberately between embedding and referencing