Project: Blog Platform API
A blog platform is the ideal second project: it introduces the concepts that separate a toy CRUD app from a real backend — multiple related resources, user accounts, stateless authentication with JWTs, and authorization rules that decide who may edit what. In this guide you will build an API with users, posts, and comments, secured with JSON Web Tokens and role-based access control, and add pagination so list endpoints stay fast as data grows. We use Express with Mongoose, but the patterns transfer to any data layer.
Project setup
Install Express and Mongoose along with the libraries that handle authentication: bcryptjs to hash passwords and jsonwebtoken to sign and verify tokens.
mkdir blog-api && cd blog-api
npm init -y
npm install express mongoose dotenv bcryptjs jsonwebtoken
Store secrets in a .env file. The JWT_SECRET must be a long random string and must never be committed.
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/blog
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=1h
A folder layout that groups by responsibility keeps the project navigable:
blog-api/
├── src/
│ ├── app.js # Express app (no listen)
│ ├── server.js # connects DB + listens
│ ├── models/ # User, Post, Comment
│ ├── middleware/auth.js
│ └── routes/ # auth, posts, comments
└── .env
Modeling related data
The three models form a graph: a Post references its author, and a Comment references both its post and its author. Storing ObjectId references (rather than embedding) keeps documents small and lets you query each collection independently. The User model hashes its password automatically through a pre("save") hook so plaintext never touches the database.
// src/models/User.js
const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, select: false },
role: { type: String, enum: ["user", "admin"], default: "user" },
},
{ timestamps: true }
);
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 12);
});
userSchema.methods.comparePassword = function (plain) {
return bcrypt.compare(plain, this.password);
};
module.exports = mongoose.model("User", userSchema);
// src/models/Post.js
const mongoose = require("mongoose");
const postSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true, maxlength: 200 },
body: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("Post", postSchema);
The Comment model mirrors this, with post and author reference fields. Using select: false on the password means it is excluded from queries unless explicitly requested with .select("+password").
Authentication with JWT
Authentication happens in two routes. Register creates a user and immediately issues a token; login verifies the password and issues a token. The token is a signed JSON payload containing the user id and role — the server never stores it, which is what makes the API stateless.
// src/routes/auth.js
const express = require("express");
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const router = express.Router();
function signToken(user) {
return jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN,
});
}
router.post("/register", async (req, res, next) => {
try {
const { name, email, password } = req.body;
const user = await User.create({ name, email, password });
res.status(201).json({ token: signToken(user) });
} catch (err) {
next(err);
}
});
router.post("/login", async (req, res, next) => {
try {
const user = await User.findOne({ email: req.body.email }).select("+password");
if (!user || !(await user.comparePassword(req.body.password))) {
return res.status(401).json({ error: "Invalid credentials" });
}
res.json({ token: signToken(user) });
} catch (err) {
next(err);
}
});
module.exports = router;
Protecting routes with middleware
Two small middleware functions enforce access. protect reads the Authorization: Bearer <token> header, verifies the signature, and attaches the decoded user to req.user. restrictTo checks the role for admin-only endpoints. Composing them lets each route declare exactly what it requires.
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
function protect(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: "Authentication required" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}
const restrictTo = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
module.exports = { protect, restrictTo };
| Middleware | Purpose | Failure status |
|---|---|---|
protect | Require a valid JWT, populate req.user | 401 |
restrictTo("admin") | Require a specific role | 403 |
| ownership check | Allow only the author (or admin) to edit | 403 |
A
401means “you are not authenticated”; a403means “you are authenticated but not allowed.” Returning the right one helps clients react correctly — a401should prompt a re-login, a403should not.
Posts with pagination and ownership
List endpoints must never return an unbounded result set. Accept page and limit query parameters, cap the limit, and use .skip()/.limit() with a parallel count for metadata. The update and delete handlers verify that the requester is the author or an admin before mutating.
// src/routes/posts.js
const express = require("express");
const Post = require("../models/Post");
const { protect } = require("../middleware/auth");
const router = express.Router();
router.get("/", async (req, res, next) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 10);
const [posts, total] = await Promise.all([
Post.find()
.sort("-createdAt")
.skip((page - 1) * limit)
.limit(limit)
.populate("author", "name"),
Post.countDocuments(),
]);
res.json({ page, limit, total, pages: Math.ceil(total / limit), posts });
} catch (err) {
next(err);
}
});
router.post("/", protect, async (req, res, next) => {
try {
const post = await Post.create({ ...req.body, author: req.user.id });
res.status(201).json(post);
} catch (err) {
next(err);
}
});
router.delete("/:id", protect, async (req, res, next) => {
try {
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "Post not found" });
if (post.author.toString() !== req.user.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
await post.deleteOne();
res.status(204).end();
} catch (err) {
next(err);
}
});
module.exports = router;
The .populate("author", "name") call replaces the stored id with the author’s name, so list responses are self-describing without a second request.
Trying it out
Register, then use the returned token to create a post.
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Ada","email":"[email protected]","password":"secret123"}' | jq -r .token)
curl -X POST http://localhost:3000/api/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Hello world","body":"My first post"}'
Output:
{
"title": "Hello world",
"body": "My first post",
"author": "665b1f2c8a4e9b0012a3c4d5",
"_id": "665b20118a4e9b0012a3c4e1",
"createdAt": "2026-06-14T10:18:09.402Z",
"updatedAt": "2026-06-14T10:18:09.402Z",
"__v": 0
}
Fetching the paginated list returns metadata alongside the posts:
{ "page": 1, "limit": 10, "total": 1, "pages": 1, "posts": [ ... ] }
Best Practices
- Hash passwords with
bcryptin apre("save")hook and mark the fieldselect: falseso it is never returned by default. - Keep authentication stateless: put only the id and role in the JWT, verify the signature on every request, and set a short expiry.
- Distinguish authentication (
401) from authorization (403), and enforce ownership before any mutating operation. - Store relationships as
ObjectIdreferences and.populate()only the fields a response needs to avoid over-fetching. - Always paginate list endpoints, cap the
limitserver-side, and returntotal/pagesmetadata for clients. - Load
JWT_SECRETand connection strings from environment variables; rotate the secret if it is ever exposed.