We'll build a complete Blog REST API from scratch using Express.js + Mongoose + JWT authentication. This project ties together everything from the previous lessons: schema design, CRUD, validation, middleware, and authentication.
A RESTful Blog API with:
# Initialize project and install dependencies
mkdir blog-api && cd blog-api
npm init -y
npm install express mongoose dotenv bcrypt jsonwebtoken express-validator cors
// package.json (relevant parts) { "name": "blog-api", "version": "1.0.0", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js" }, "dependencies": { "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.3" } }
# .env file
MONGODB_URI=mongodb://localhost:27017/blogapi
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=7d
PORT=5000
NODE_ENV=development
const mongoose = require('mongoose') const connectDB = async () => { try { const conn = await mongoose.connect(process.env.MONGODB_URI) console.log(`MongoDB Connected: ${conn.connection.host}`) } catch (err) { console.error(`Error: ${err.message}`) process.exit(1) } } module.exports = connectDB
const mongoose = require('mongoose') const bcrypt = require('bcrypt') const userSchema = new mongoose.Schema({ username: { type: String, required: [true, 'Username required'], unique: true, trim: true, minlength: [3, 'Username must be at least 3 chars'], maxlength: [30, 'Username max 30 chars'], match: [/^[a-zA-Z0-9_]+$/, 'Alphanumeric and underscores only'] }, email: { type: String, required: [true, 'Email required'], unique: true, lowercase: true, trim: true, match: [/\S+@\S+\.\S+/, 'Please enter a valid email'] }, password: { type: String, required: [true, 'Password required'], minlength: [8, 'Password must be at least 8 chars'], select: false // never return password in queries }, role: { type: String, enum: ['admin', 'author', 'reader'], default: 'reader' }, bio: { type: String, maxlength: 300 }, avatar: { type: String, default: '' } }, { timestamps: true }) // Hash password before saving userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next() this.password = await bcrypt.hash(this.password, 12) next() }) // Instance method: compare passwords userSchema.methods.comparePassword = async function(candidatePw) { return bcrypt.compare(candidatePw, this.password) } module.exports = mongoose.model('User', userSchema)
const mongoose = require('mongoose') const commentSchema = new mongoose.Schema({ author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, content: { type: String, required: true, maxlength: 1000 }, createdAt: { type: Date, default: Date.now } }) const postSchema = new mongoose.Schema({ title: { type: String, required: [true, 'Title is required'], trim: true, maxlength: [200, 'Title max 200 chars'] }, slug: { type: String, required: true, unique: true, lowercase: true }, body: { type: String, required: [true, 'Body is required'] }, author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, tags: [{ type: String, trim: true, lowercase: true }], status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft' }, views: { type: Number, default: 0 }, comments: [commentSchema] }, { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }) // Virtual: comment count postSchema.virtual('commentCount').get(function() { return this.comments.length }) // Index for fast slug lookups and text search postSchema.index({ slug: 1 }) postSchema.index({ author: 1, createdAt: -1 }) postSchema.index({ title: 'text', body: 'text' }) module.exports = mongoose.model('Post', postSchema)
const jwt = require('jsonwebtoken') const User = require('../models/User') const signToken = (userId) => { return jwt.sign( { id: userId }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN } ) } // POST /api/auth/register exports.register = async (req, res) => { try { const { username, email, password } = req.body // Check if user already exists const existing = await User.findOne({ $or: [{ email }, { username }] }) if (existing) { return res.status(400).json({ success: false, message: 'Email or username already in use' }) } // Create user (password hashed by pre-save hook) const user = await User.create({ username, email, password }) // Sign JWT and return const token = signToken(user._id) res.status(201).json({ success: true, token, user: { id: user._id, username: user.username, email: user.email, role: user.role } }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } } // POST /api/auth/login exports.login = async (req, res) => { try { const { email, password } = req.body // Explicitly select password (hidden by default) const user = await User.findOne({ email }).select('+password') if (!user || !(await user.comparePassword(password))) { return res.status(401).json({ success: false, message: 'Invalid credentials' }) } const token = signToken(user._id) res.json({ success: true, token, user: { id: user._id, username: user.username, email: user.email, role: user.role } }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } }
const jwt = require('jsonwebtoken') const User = require('../models/User') exports.protect = async (req, res, next) => { try { // Extract token from Authorization header let token if (req.headers.authorization?.startsWith('Bearer ')) { token = req.headers.authorization.split(' ')[1] } if (!token) { return res.status(401).json({ success: false, message: 'Not authenticated' }) } // Verify token const decoded = jwt.verify(token, process.env.JWT_SECRET) // Attach user to request object req.user = await User.findById(decoded.id).select('-password') if (!req.user) { return res.status(401).json({ success: false, message: 'User no longer exists' }) } next() } catch (err) { res.status(401).json({ success: false, message: 'Invalid or expired token' }) } } // Role-based authorization middleware exports.authorize = (...roles) => { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ success: false, message: `Role '${req.user.role}' is not authorized for this action` }) } next() } }
const Post = require('../models/Post') // GET /api/posts — list with pagination + filters exports.getPosts = async (req, res) => { try { const page = parseInt(req.query.page) || 1 const limit = parseInt(req.query.limit) || 10 const skip = (page - 1) * limit const filter = { status: 'published' } // Optional tag filter: ?tag=mongodb if (req.query.tag) filter.tags = req.query.tag // Optional text search: ?search=aggregation if (req.query.search) filter.$text = { $search: req.query.search } const [posts, total] = await Promise.all([ Post.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .populate('author', 'username avatar') .select('-body -comments') // list view: no full body/comments .lean(), Post.countDocuments(filter) ]) res.json({ success: true, data: posts, pagination: { total, page, pages: Math.ceil(total / limit), hasMore: page * limit < total } }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } } // GET /api/posts/:slug exports.getPost = async (req, res) => { try { const post = await Post.findOneAndUpdate( { slug: req.params.slug, status: 'published' }, { $inc: { views: 1 } }, // increment views on each read { new: true } ).populate('author', 'username bio avatar') .populate('comments.author', 'username avatar') if (!post) { return res.status(404).json({ success: false, message: 'Post not found' }) } res.json({ success: true, data: post }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } } // POST /api/posts — create post (auth required) exports.createPost = async (req, res) => { try { const { title, body, tags, status } = req.body // Auto-generate slug from title const slug = title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') const post = await Post.create({ title, slug, body, tags, status, author: req.user._id // from auth middleware }) res.status(201).json({ success: true, data: post }) } catch (err) { if (err.code === 11000) { return res.status(400).json({ success: false, message: 'Slug already exists' }) } res.status(500).json({ success: false, message: err.message }) } } // PUT /api/posts/:id — update post (auth + owner only) exports.updatePost = async (req, res) => { try { const post = await Post.findById(req.params.id) if (!post) return res.status(404).json({ success: false, message: 'Not found' }) // Only author or admin can update if (post.author.toString() !== req.user._id.toString() && req.user.role !== 'admin') { return res.status(403).json({ success: false, message: 'Not authorized' }) } const { title, body, tags, status } = req.body const updated = await Post.findByIdAndUpdate( req.params.id, { $set: { title, body, tags, status } }, { new: true, runValidators: true } ) res.json({ success: true, data: updated }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } } // DELETE /api/posts/:id exports.deletePost = async (req, res) => { try { const post = await Post.findById(req.params.id) if (!post) return res.status(404).json({ success: false, message: 'Not found' }) if (post.author.toString() !== req.user._id.toString() && req.user.role !== 'admin') { return res.status(403).json({ success: false, message: 'Not authorized' }) } await post.deleteOne() res.json({ success: true, message: 'Post deleted' }) } catch (err) { res.status(500).json({ success: false, message: err.message }) } }
// routes/authRoutes.js const router = require('express').Router() const { register, login } = require('../controllers/authController') router.post('/register', register) router.post('/login', login) module.exports = router // routes/postRoutes.js const router = require('express').Router() const { protect, authorize } = require('../middleware/auth') const ctrl = require('../controllers/postController') router.get('/', ctrl.getPosts) router.get('/:slug', ctrl.getPost) router.post('/', protect, authorize('author', 'admin'), ctrl.createPost) router.put('/:id', protect, ctrl.updatePost) router.delete('/:id', protect, ctrl.deletePost) module.exports = router
// server.js — main entry point require('dotenv').config() const express = require('express') const cors = require('cors') const connectDB = require('./config/db') const app = express() connectDB() app.use(cors()) app.use(express.json()) app.use('/api/auth', require('./routes/authRoutes')) app.use('/api/posts', require('./routes/postRoutes')) // Global error handler app.use((err, req, res, next) => { console.error(err.stack) res.status(500).json({ success: false, message: 'Internal server error' }) }) const PORT = process.env.PORT || 5000 app.listen(PORT, () => console.log(`Server running on port ${PORT}`))
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
None | Register new user, returns JWT token |
| POST | /api/auth/login |
None | Login with email + password, returns JWT token |
| GET | /api/posts |
None | List published posts. Query: ?page=1&limit=10&tag=mongodb&search=term |
| GET | /api/posts/:slug |
None | Get single post by slug, increments view count |
| POST | /api/posts |
author / admin | Create a new post (auto-generates slug from title) |
| PUT | /api/posts/:id |
owner / admin | Update post (only author or admin can edit) |
| DELETE | /api/posts/:id |
owner / admin | Delete post (only author or admin can delete) |
# Start the server npm run dev # Register a new user curl -X POST http://localhost:5000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"alice","email":"alice@example.com","password":"secret123"}' # Login and save token TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"secret123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # Get all posts (no auth needed) curl http://localhost:5000/api/posts # Get posts with pagination and tag filter curl "http://localhost:5000/api/posts?page=1&limit=5&tag=mongodb" # Create a post (requires auth) curl -X POST http://localhost:5000/api/posts \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{ "title": "My First Post", "body": "This is the full content of my post...", "tags": ["mongodb", "tutorial"], "status": "published" }' # Get a single post by slug curl http://localhost:5000/api/posts/my-first-post # Update a post curl -X PUT http://localhost:5000/api/posts/POST_ID \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title": "Updated Title", "status": "published"}' # Delete a post curl -X DELETE http://localhost:5000/api/posts/POST_ID \ -H "Authorization: Bearer $TOKEN"
You've learned everything from installation to building a production-ready REST API.
Next steps: Add rate limiting (express-rate-limit), file uploads (multer + GridFS), and deploy to MongoDB Atlas + Heroku/Railway.