Web Development

Building a REST API with Node.js and Express: Complete Guide

Building a REST API with Node.js and Express: Complete Guide

Web Development March 12, 2026 · 2 min read · 366 words

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 nodemon

Here 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.json

Setting 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/1

Production Best Practices

Before deploying, ensure you follow these critical practices:

  1. Use environment variables for all secrets and configuration (port, database path, API tokens)
  2. Enable HTTPS in production — use a reverse proxy like Nginx or deploy behind a load balancer
  3. Set appropriate CORS origins — never use * in production
  4. Add request logging to a persistent store for debugging and auditing
  5. Implement graceful shutdown to finish in-flight requests before stopping the server
  6. Use process managers like PM2 or deploy with Docker for reliability
  7. 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.

node.js rest api tutorial express api tutorial build rest api nodejs express 5 api guide 2026

About the Author

J
Jordan Lee
Senior Editor, TopVideoHub
Jordan Lee is the senior editor at TopVideoHub, specializing in technology, entertainment, gaming, and digital culture. With extensive experience in content curation and editorial analysis, Jordan leads our coverage of trending topics across multiple regions and categories.

Related Articles