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.
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
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest --runInBand"
}
}
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
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.
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.
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);
createdAt and updatedAt.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.
helmet() (security) โ cors() (CORS) โ morgan() (logging) โ express.json() (parsing) โ your routes โ error handlerconst 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;
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.
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.
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;
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.
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'
});
};