Building a REST API with Node.js and Express: Complete Guide
Building a REST API with Node.js and Express: Complete Guide
Building a REST API with Node.js and Express: Complete Tutorial for 2026
REST APIs remain the backbone of modern web applications. Whether you are building a mobile backend, a microservice, or a full-stack web app, knowing how to design and implement a robust REST API is an essential skill. In this comprehensive tutorial, you will build a complete REST API from scratch using Node.js 22 and Express 5, covering everything from project setup to authentication, validation, error handling, and deployment.
By the end, you will have a production-ready API with proper structure, middleware, database integration, and security best practices.
Project Setup and Dependencies
Initialize the Project
Start by creating a new project directory and initializing it with npm. We will use ES modules (ESM) throughout, which is the standard in Node.js 22:
mkdir bookshelf-api
cd bookshelf-api
npm init -y
# Set type to module in package.json for ESM support
npm pkg set type="module"
# Install dependencies
npm install express@5 better-sqlite3 zod helmet cors morgan dotenv
npm install -D nodemonHere is what each dependency does:
- express@5 — Web framework with native async error handling (new in v5)
- better-sqlite3 — Fast, synchronous SQLite driver perfect for tutorials and small-to-medium apps
- zod — TypeScript-first schema validation
- helmet — Sets security HTTP headers
- cors — Cross-Origin Resource Sharing middleware
- morgan — HTTP request logger
- dotenv — Environment variable management
Project Structure
Organize your project following a modular architecture:
bookshelf-api/
├── src/
│ ├── server.js # Entry point
│ ├── app.js # Express app configuration
│ ├── database.js # Database connection and setup
│ ├── routes/
│ │ └── books.js # Book routes
│ ├── middleware/
│ │ ├── validate.js # Request validation middleware
│ │ └── errorHandler.js # Global error handler
│ └── schemas/
│ └── book.js # Zod validation schemas
├── .env
├── .gitignore
└── package.jsonSetting Up the Express Application
Database Configuration
First, set up the SQLite database with better-sqlite3:
// src/database.js
import Database from 'better-sqlite3';
import { resolve } from 'path';
const DB_PATH = process.env.DB_PATH || resolve('data', 'bookshelf.db');
const db = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
isbn TEXT UNIQUE,
published_year INTEGER,
genre TEXT,
summary TEXT,
rating REAL DEFAULT 0 CHECK(rating >= 0 AND rating <= 5),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
export default db;Express App Configuration
Configure Express with essential middleware:
// src/app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import bookRoutes from './routes/books.js';
import { errorHandler } from './middleware/errorHandler.js';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Request parsing
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/v1/books', bookRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.originalUrl} not found`,
});
});
// Global error handler (must be last)
app.use(errorHandler);
export default app;Server Entry Point
// src/server.js
import 'dotenv/config';
import app from './app.js';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});Validation with Zod
Define strict validation schemas for request data. This prevents malformed data from reaching your database and provides clear error messages to API consumers:
// src/schemas/book.js
import { z } from 'zod';
export const createBookSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
author: z.string().min(1, 'Author is required').max(100),
isbn: z.string().regex(/^(97[89])\d{10}$/, 'Invalid ISBN-13 format').optional(),
published_year: z.number().int().min(1450).max(2026).optional(),
genre: z.string().max(50).optional(),
summary: z.string().max(2000).optional(),
rating: z.number().min(0).max(5).optional(),
});
export const updateBookSchema = createBookSchema.partial();
export const querySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['title', 'author', 'rating', 'published_year', 'created_at']).default('created_at'),
order: z.enum(['asc', 'desc']).default('desc'),
genre: z.string().optional(),
search: z.string().max(100).optional(),
});Create a reusable validation middleware:
// src/middleware/validate.js
export function validate(schema, source = 'body') {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return res.status(400).json({
error: 'Validation Error',
details: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
req[source] = result.data; // Replace with parsed/coerced data
next();
};
}Building the Book Routes (CRUD)
Now implement the full CRUD operations. Express 5 natively supports async route handlers, so thrown errors automatically reach the error handler without needing try/catch wrappers:
// src/routes/books.js
import { Router } from 'express';
import db from '../database.js';
import { createBookSchema, updateBookSchema, querySchema } from '../schemas/book.js';
import { validate } from '../middleware/validate.js';
const router = Router();
// GET /api/v1/books - List all books with pagination and filtering
router.get('/', validate(querySchema, 'query'), (req, res) => {
const { page, limit, sort, order, genre, search } = req.query;
const offset = (page - 1) * limit;
let whereClause = 'WHERE 1=1';
const params = [];
if (genre) {
whereClause += ' AND genre = ?';
params.push(genre);
}
if (search) {
whereClause += ' AND (title LIKE ? OR author LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
const countRow = db.prepare(
`SELECT COUNT(*) as total FROM books ${whereClause}`
).get(...params);
const books = db.prepare(
`SELECT * FROM books ${whereClause}
ORDER BY ${sort} ${order}
LIMIT ? OFFSET ?`
).all(...params, limit, offset);
res.json({
data: books,
pagination: {
page,
limit,
total: countRow.total,
totalPages: Math.ceil(countRow.total / limit),
},
});
});
// GET /api/v1/books/:id - Get a single book
router.get('/:id', (req, res) => {
const book = db.prepare('SELECT * FROM books WHERE id = ?').get(req.params.id);
if (!book) {
return res.status(404).json({
error: 'Not Found',
message: `Book with id ${req.params.id} not found`,
});
}
res.json({ data: book });
});
// POST /api/v1/books - Create a new book
router.post('/', validate(createBookSchema), (req, res) => {
const { title, author, isbn, published_year, genre, summary, rating } = req.body;
const stmt = db.prepare(`
INSERT INTO books (title, author, isbn, published_year, genre, summary, rating)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(title, author, isbn, published_year, genre, summary, rating || 0);
const newBook = db.prepare('SELECT * FROM books WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: newBook });
});
// PUT /api/v1/books/:id - Full update
router.put('/:id', validate(createBookSchema), (req, res) => {
const existing = db.prepare('SELECT id FROM books WHERE id = ?').get(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Not Found' });
}
const { title, author, isbn, published_year, genre, summary, rating } = req.body;
db.prepare(`
UPDATE books
SET title = ?, author = ?, isbn = ?, published_year = ?,
genre = ?, summary = ?, rating = ?, updated_at = datetime('now')
WHERE id = ?
`).run(title, author, isbn, published_year, genre, summary, rating || 0, req.params.id);
const updated = db.prepare('SELECT * FROM books WHERE id = ?').get(req.params.id);
res.json({ data: updated });
});
// PATCH /api/v1/books/:id - Partial update
router.patch('/:id', validate(updateBookSchema), (req, res) => {
const existing = db.prepare('SELECT * FROM books WHERE id = ?').get(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Not Found' });
}
const fields = Object.keys(req.body);
if (fields.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
const setClause = fields.map((f) => `${f} = ?`).join(', ');
const values = fields.map((f) => req.body[f]);
db.prepare(
`UPDATE books SET ${setClause}, updated_at = datetime('now') WHERE id = ?`
).run(...values, req.params.id);
const updated = db.prepare('SELECT * FROM books WHERE id = ?').get(req.params.id);
res.json({ data: updated });
});
// DELETE /api/v1/books/:id - Delete a book
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM books WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Not Found' });
}
res.status(204).end();
});
export default router;Global Error Handling
A robust error handler catches all unhandled errors and returns consistent responses:
// src/middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
// SQLite constraint errors
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({
error: 'Conflict',
message: 'A resource with that unique value already exists',
});
}
// JSON parse errors
if (err.type === 'entity.parse.failed') {
return res.status(400).json({
error: 'Bad Request',
message: 'Invalid JSON in request body',
});
}
// Default to 500
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: statusCode === 500 ? 'Internal Server Error' : err.message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
}Adding Rate Limiting and Authentication
Rate Limiting
Protect your API from abuse with rate limiting:
npm install express-rate-limit// In src/app.js, add after other middleware
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false,
message: {
error: 'Too Many Requests',
message: 'Rate limit exceeded. Try again in 15 minutes.',
},
});
app.use('/api/', limiter);Simple Token Authentication
For production APIs, implement at minimum a token-based authentication middleware:
// src/middleware/auth.js
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Missing or invalid Authorization header',
});
}
const token = authHeader.slice(7);
// In production, verify JWT or check against database
if (token !== process.env.API_TOKEN) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid API token',
});
}
next();
}
// Apply to write operations in routes/books.js:
// router.post('/', authenticate, validate(createBookSchema), (req, res) => {...Testing Your API
Test your endpoints with curl commands:
# Create a book
curl -X POST http://localhost:3000/api/v1/books \
-H "Content-Type: application/json" \
-d '{
"title": "The Pragmatic Programmer",
"author": "David Thomas, Andrew Hunt",
"isbn": "9780135957059",
"published_year": 2019,
"genre": "programming",
"rating": 4.8
}'
# List books with pagination and search
curl "http://localhost:3000/api/v1/books?page=1&limit=10&search=pragmatic"
# Update a book
curl -X PATCH http://localhost:3000/api/v1/books/1 \
-H "Content-Type: application/json" \
-d '{"rating": 5.0}'
# Delete a book
curl -X DELETE http://localhost:3000/api/v1/books/1Production Best Practices
Before deploying, ensure you follow these critical practices:
- Use environment variables for all secrets and configuration (port, database path, API tokens)
- Enable HTTPS in production — use a reverse proxy like Nginx or deploy behind a load balancer
- Set appropriate CORS origins — never use
*in production - Add request logging to a persistent store for debugging and auditing
- Implement graceful shutdown to finish in-flight requests before stopping the server
- Use process managers like PM2 or deploy with Docker for reliability
- Version your API (we used
/api/v1/) so you can evolve without breaking clients
This tutorial gives you a solid foundation. From here, you can extend the API with JWT authentication using jsonwebtoken, add file uploads with multer, implement WebSocket real-time updates, or migrate to PostgreSQL for larger-scale applications.