🏠 Home / Hub

🚀 MongoDB — Lesson 8: REST API Project

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.

1. Project Overview

A RESTful Blog API with:

2. Folder Structure

blog-api/
├── server.js ← Entry point, starts Express
├── .env ← Environment variables
├── .env.example ← Template (commit this, not .env)
├── package.json
├── config/
│ └── db.js ← MongoDB connection
├── models/
│ ├── User.js ← User schema + bcrypt hook
│ └── Post.js ← Post schema with embedded comments
├── controllers/
│ ├── authController.js ← register, login
│ └── postController.js ← CRUD + pagination
├── middleware/
│ └── auth.js ← JWT verify middleware
└── routes/
├── authRoutes.js
└── postRoutes.js

3. package.json and .env Setup

# 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

4. Database Connection (config/db.js)

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

5. User Model (models/User.js)

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)

6. Post Model (models/Post.js)

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)

7. Auth Controller (controllers/authController.js)

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 })
  }
}

8. Auth Middleware (middleware/auth.js)

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()
  }
}

9. Post Controller (controllers/postController.js)

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 })
  }
}

10. Routes and server.js

// 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}`))

11. API Endpoints

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)

12. Testing with curl

# 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"
🎉

MongoDB Complete!

You've learned everything from installation to building a production-ready REST API.

Documents & BSON CRUD Operations Schema Design Query Operators Aggregation Pipeline Indexes & Performance Mongoose ODM JWT Auth REST API

Next steps: Add rate limiting (express-rate-limit), file uploads (multer + GridFS), and deploy to MongoDB Atlas + Heroku/Railway.

📌 Study Checklist