๐ŸŸข Node.js ยท Beginner

REST API with Express.js + MongoDB

โฑ 40 minutes๐ŸŸข Node.js 20๐Ÿƒ MongoDB๐Ÿ“ฆ Express.js 4

Express.js is the most popular Node.js web framework. This tutorial builds a complete CRUD API to manage blog articles: creation, reading, updating, deletion, with validation, middlewares and clean error handling.

1. Initialize the project

Terminal
mkdir api-blog && cd api-blog
npm init -y

npm install express mongoose dotenv cors helmet morgan joi
npm install --save-dev nodemon jest supertest
package.json (scripts)
{
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest --runInBand"
  }
}

2. Project structure

Directory tree
src/
โ”œโ”€โ”€ server.js           # Entry point
โ”œโ”€โ”€ app.js              # Express configuration
โ”œโ”€โ”€ models/
โ”‚   โ””โ”€โ”€ Article.js      # Mongoose schema
โ”œโ”€โ”€ routes/
โ”‚   โ””โ”€โ”€ articles.js     # CRUD routes
โ”œโ”€โ”€ middlewares/
โ”‚   โ”œโ”€โ”€ validate.js     # Joi validation
โ”‚   โ””โ”€โ”€ errorHandler.js # Error handling
โ””โ”€โ”€ __tests__/
    โ””โ”€โ”€ articles.test.js

3. Mongoose model

๐Ÿ“– Term: Schema (Mongoose Schema)

Definition: Blueprint that defines the structure of a MongoDB document: field types, validations, default values and methods.

Purpose: Enforce structure and rules on MongoDB's flexible data.

Why here: Although MongoDB is schema-less, Mongoose enforces a schema to prevent chaos and maintain data integrity at the application level.

๐Ÿ“– Term: ODM (Object Document Mapper)

Definition: Abstraction layer that maps MongoDB documents to JavaScript objects (like an ORM for SQL).

Purpose: Simplify interaction with MongoDB by using methods instead of raw query code.

Why here: Mongoose is the most popular ODM for Node.js + MongoDB, offering validation, middlewares and type-safe queries.

src/models/Article.js
const mongoose = require('mongoose');

const articleSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    trim: true,
    maxlength: [200, 'Title must not exceed 200 characters']
  },
  content: {
    type: String,
    required: [true, 'Content is required'],
    minlength: [10, 'Content must be at least 10 characters']
  },
  author: {
    type: String,
    required: true
  },
  tags: [{
    type: String,
    lowercase: true,
    trim: true
  }],
  published: {
    type: Boolean,
    default: false
  },
  views: {
    type: Number,
    default: 0
  }
}, {
  timestamps: true,  // Automatically add createdAt and updatedAt
  versionKey: false
});

// Index for full-text search
articleSchema.index({ title: 'text', content: 'text' });

// Instance method
articleSchema.methods.getSummary = function() {
  return `${this.title} โ€” ${this.content.substring(0, 100)}...`;
};

module.exports = mongoose.model('Article', articleSchema);
The schema defines the structure: each document will have title, content, author, etc. Validators (required, maxlength, minlength) execute before saving to the database. The timestamps automatically add createdAt and updatedAt.

4. Express configuration and middlewares

๐Ÿ“– Term: Middleware

Definition: Function that intercepts the HTTP request before it reaches the route handler, performs an action (validation, logging, security), then passes the request to the next middleware with next().

Purpose: Decouple cross-cutting concerns (security, logging, authentication) from business logic.

Why here: Express functions as a chain of middlewares: each app.use() adds a link to this chain. The request traverses all middlewares in order until it reaches the route.

Middleware chain: request โ†’ helmet() (security) โ†’ cors() (CORS) โ†’ morgan() (logging) โ†’ express.json() (parsing) โ†’ your routes โ†’ error handler
src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const articleRoutes = require('./routes/articles');
const errorHandler = require('./middlewares/errorHandler');

const app = express();

// โ”€โ”€ Security middlewares โ”€โ”€
app.use(helmet());          // HTTP security headers
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
}));
app.use(morgan('dev'));      // Request logging
app.use(express.json());     // JSON parser

// โ”€โ”€ Routes โ”€โ”€
app.use('/api/articles', articleRoutes);
app.get('/health', (req, res) => res.json({ status: 'ok' }));

// โ”€โ”€ 404 โ”€โ”€
app.use((req, res) => {
  res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});

// โ”€โ”€ Global error handler โ”€โ”€
app.use(errorHandler);

module.exports = app;

5. CRUD routes

๐Ÿ“– Term: Validation

Definition: Verification that data sent by the client respects business rules (types, formats, lengths) before processing.

Purpose: Reject malformed data early, before it pollutes the database.

Why here: Joi provides declarative server-side validation, independent of the Mongoose schema, for two layers of protection.

๐Ÿ“– Term: Pagination

Definition: Technique of dividing results into pages: return a subset (e.g. 10 results) instead of all results.

Purpose: Reduce memory and network load, improve API performance.

Why here: skip((page - 1) * limit) skips the first N documents, limit() returns at most M. Tradeoff: complete pagination but slower than keyset pagination.

src/routes/articles.js
const express = require('express');
const router = express.Router();
const Article = require('../models/Article');
const Joi = require('joi');

// โ”€โ”€ Joi validation โ”€โ”€
const articleSchema = Joi.object({
  title: Joi.string().max(200).required(),
  content: Joi.string().min(10).required(),
  author: Joi.string().required(),
  tags: Joi.array().items(Joi.string()),
  published: Joi.boolean()
});

const validate = (schema) => (req, res, next) => {
  const { error } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(422).json({
      error: 'Invalid data',
      details: error.details.map(d => d.message)
    });
  }
  next();
};

// โ”€โ”€ GET /api/articles โ€” with pagination and filters โ”€โ”€
router.get('/', async (req, res, next) => {
  try {
    const { page = 1, limit = 10, tag, published, search } = req.query;
    const filter = {};

    if (tag) filter.tags = tag;
    if (published !== undefined) filter.published = published === 'true';
    if (search) filter.$text = { $search: search };

    const [articles, total] = await Promise.all([
      Article.find(filter)
        .sort({ createdAt: -1 })
        .skip((Number(page) - 1) * Number(limit))
        .limit(Number(limit))
        .select('-content'),  // Exclude content from list
      Article.countDocuments(filter)
    ]);

    res.json({
      data: articles,
      pagination: {
        page: Number(page),
        limit: Number(limit),
        total,
        pages: Math.ceil(total / Number(limit))
      }
    });
  } catch (err) { next(err); }
});

// โ”€โ”€ GET /api/articles/:id โ”€โ”€
router.get('/:id', async (req, res, next) => {
  try {
    const article = await Article.findByIdAndUpdate(
      req.params.id,
      { $inc: { views: 1 } },  // Increment views
      { new: true }
    );
    if (!article) return res.status(404).json({ error: 'Article not found' });
    res.json(article);
  } catch (err) { next(err); }
});

// โ”€โ”€ POST /api/articles โ”€โ”€
router.post('/', validate(articleSchema), async (req, res, next) => {
  try {
    const article = await Article.create(req.body);
    res.status(201).json(article);
  } catch (err) { next(err); }
});

// โ”€โ”€ PATCH /api/articles/:id โ”€โ”€
router.patch('/:id', async (req, res, next) => {
  try {
    const article = await Article.findByIdAndUpdate(
      req.params.id, req.body, { new: true, runValidators: true }
    );
    if (!article) return res.status(404).json({ error: 'Article not found' });
    res.json(article);
  } catch (err) { next(err); }
});

// โ”€โ”€ DELETE /api/articles/:id โ”€โ”€
router.delete('/:id', async (req, res, next) => {
  try {
    const article = await Article.findByIdAndDelete(req.params.id);
    if (!article) return res.status(404).json({ error: 'Article not found' });
    res.json({ message: 'Article deleted', id: req.params.id });
  } catch (err) { next(err); }
});

module.exports = router;

6. Global error handler

๐Ÿ“– Term: Global error handler

Definition: Special Express middleware (4 parameters: err, req, res, next) that captures all errors thrown or passed via next(err) in the application.

Purpose: Centralize error handling instead of duplicating try-catch everywhere, to provide consistent responses.

Why here: Without a global handler, uncaught errors cause crashes. With it, they return clean JSON responses to the client and logs to the server.

Mongoose errors (ValidationError, CastError, code 11000) have specific signatures. The global handler recognizes them and returns appropriate HTTP codes (422 for validation, 400 for invalid format, 409 for duplicate).
src/middlewares/errorHandler.js
module.exports = (err, req, res, next) => {
  console.error(err.stack);

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      error: 'Invalid data',
      details: Object.values(err.errors).map(e => e.message)
    });
  }

  // Invalid MongoDB ID (CastError)
  if (err.name === 'CastError') {
    return res.status(400).json({ error: 'Invalid ID' });
  }

  // Duplicate (unique index)
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return res.status(409).json({ error: `${field} already in use` });
  }

  res.status(err.status || 500).json({
    error: err.message || 'Internal server error'
  });
};
The API is available on http://localhost:3000. Test with curl or Postman: