Type something to search...
Building a REST API with Express and MongoDB

Building a REST API with Express and MongoDB

Express and MongoDB have been a default pairing for Node.js backends for over a decade, and for good reason. JSON goes in, JSON comes out, and the document model maps so directly to API resources that a basic CRUD endpoint is only a few lines of code. The hard part isn't getting something working. It's getting something that validates input, returns the right status codes, paginates properly, and doesn't fall over on the first malformed request.

This tutorial builds a small but production-shaped REST API for a book catalog. It uses Express 5, which finally handles rejected promises in async route handlers natively, and the official MongoDB Node.js driver directly, without an ODM. You'll end up with a structure you can grow: a database module, a router per resource, request validation, consistent error responses, and indexes created at startup.

This guide covers project setup, the database connection, each CRUD endpoint, filtering and pagination, validation, error handling, and the security details that tutorials usually skip.

Project Setup

mkdir books-api && cd books-api
npm init -y
npm install express mongodb zod

Set the project to ES modules and add a start script:

{
  "name": "books-api",
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/index.js",
    "start": "node --env-file=.env src/index.js"
  }
}

And a .env file:

MONGODB_URI="mongodb://localhost:27017"
MONGODB_DB="catalog"
PORT=3000

The layout we're building toward:

src/
  index.js        # starts the server
  app.js          # builds the Express app
  db.js           # MongoDB client and collections
  books/
    router.js     # /api/books routes
    schema.js     # validation schemas
  errors.js       # HTTP error class and error middleware

Separating app.js from index.js means you can import the app into tests without starting a real server.

The Database Module

Create a single MongoClient for the whole process and expose the collections you need:

// src/db.js
import { MongoClient } from "mongodb";

export const client = new MongoClient(process.env.MONGODB_URI, {
  appName: "books-api",
  serverSelectionTimeoutMS: 5000,
});

const db = client.db(process.env.MONGODB_DB);
export const books = db.collection("books");

export async function connectDb() {
  await client.connect();
  await Promise.all([
    books.createIndex({ isbn: 1 }, { unique: true }),
    books.createIndex({ author: 1, publishedYear: -1 }),
    books.createIndex({ genres: 1 }),
  ]);
}

createIndex is idempotent: if the index already exists with the same definition, the call is a no-op. Creating indexes at startup is fine for a small service. For large collections, you'd move this into a migration step so a deploy doesn't kick off a long index build.

Reusing one client matters more than almost anything else in this file. The client owns a connection pool, and creating one per request is the most common cause of slow, connection-exhausted Node.js APIs. Connecting Node.js to MongoDB with the Official Driver goes deeper on client configuration.

Errors First

Before writing routes, decide how errors look. A small error class lets route code throw an HTTP status with a message, and a single middleware turns every error into a consistent JSON response:

// src/errors.js
import { MongoServerError } from "mongodb";

export class HttpError extends Error {
  constructor(status, message, details) {
    super(message);
    this.status = status;
    this.details = details;
  }
}

export function notFound(req, res) {
  res.status(404).json({ error: "Not found" });
}

export function errorHandler(err, req, res, next) {
  if (err instanceof HttpError) {
    return res
      .status(err.status)
      .json({ error: err.message, details: err.details });
  }

  if (err instanceof MongoServerError && err.code === 11000) {
    return res.status(409).json({
      error: "Duplicate value",
      details: err.keyValue,
    });
  }

  if (err.type === "entity.parse.failed") {
    return res.status(400).json({ error: "Malformed JSON body" });
  }

  console.error(err);
  res.status(500).json({ error: "Internal server error" });
}

Two things to note. Duplicate key errors (code 11000) from the unique ISBN index map to 409 Conflict, which is what a client needs to know. And unexpected errors return a generic message while the details go to your logs. Never send raw error messages or stack traces to clients; they can reveal collection names, query shapes, and infrastructure details.

Validating Input

Validation belongs at the edge of your API. This example uses Zod, but any schema library works the same way:

// src/books/schema.js
import { z } from "zod";
import { HttpError } from "../errors.js";

const currentYear = new Date().getFullYear();

const bookFields = {
  title: z.string().trim().min(1).max(300),
  author: z.string().trim().min(1).max(200),
  isbn: z.string().regex(/^(97[89])?\d{9}[\dX]$/, "Invalid ISBN"),
  publishedYear: z.number().int().min(1450).max(currentYear),
  genres: z.array(z.string().trim().min(1)).max(10),
  pages: z.number().int().positive(),
  inStock: z.boolean(),
};

export const bookCreateSchema = z.object({
  ...bookFields,
  genres: bookFields.genres.default([]),
  pages: bookFields.pages.optional(),
  inStock: bookFields.inStock.default(true),
});

// No defaults here: a PATCH should only touch the fields the client sent
export const bookUpdateSchema = z
  .object(bookFields)
  .partial()
  .refine(
    (data) => Object.keys(data).length > 0,
    "At least one field is required",
  );

export const listQuerySchema = z.object({
  author: z.string().optional(),
  genre: z.string().optional(),
  inStock: z.enum(["true", "false"]).optional(),
  sort: z
    .enum(["title", "-title", "publishedYear", "-publishedYear"])
    .default("title"),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export function parse(schema, data) {
  const result = schema.safeParse(data);
  if (!result.success) {
    throw new HttpError(400, "Validation failed", result.error.issues);
  }
  return result.data;
}

The schema does more than reject bad input. It strips unknown fields, so a client can't sneak { "_id": ..., "isAdmin": true } into a document. That also shuts down a whole class of NoSQL injection attacks where an attacker sends an object like { "$ne": null } where you expected a string: Zod rejects it because it isn't a string.

The Books Router

Now the resource routes. Start with a helper to turn URL parameters into ObjectId values safely:

// src/books/router.js
import { Router } from "express";
import { ObjectId } from "mongodb";
import { books } from "../db.js";
import { HttpError } from "../errors.js";
import {
  bookCreateSchema,
  bookUpdateSchema,
  listQuerySchema,
  parse,
} from "./schema.js";

export const router = Router();

function toObjectId(id) {
  if (!/^[0-9a-f]{24}$/i.test(id)) {
    throw new HttpError(400, "Invalid book id");
  }
  return new ObjectId(id);
}

function serialize(book) {
  const { _id, ...rest } = book;
  return { id: _id.toHexString(), ...rest };
}

Renaming _id to id in responses is optional, but it keeps MongoDB's internals out of your public contract. If you ever move a resource to another store, clients don't care.

Create: POST /api/books

router.post("/", async (req, res) => {
  const data = parse(bookCreateSchema, req.body);
  const now = new Date();
  const doc = { ...data, createdAt: now, updatedAt: now };

  const { insertedId } = await books.insertOne(doc);

  res
    .status(201)
    .location(`/api/books/${insertedId}`)
    .json(serialize({ _id: insertedId, ...doc }));
});

Return 201 Created with a Location header pointing at the new resource. If the ISBN already exists, insertOne throws a duplicate key error, and the error middleware turns it into a 409. Because Express 5 catches rejected promises from async handlers, you don't need a try/catch or an asyncHandler wrapper here.

Read One: GET /api/books/:id

router.get("/:id", async (req, res) => {
  const book = await books.findOne({ _id: toObjectId(req.params.id) });
  if (!book) {
    throw new HttpError(404, "Book not found");
  }
  res.json(serialize(book));
});

List: GET /api/books

The list endpoint is where most of the thought goes. It supports filtering, sorting from a fixed set of options, and pagination:

router.get("/", async (req, res) => {
  const q = parse(listQuerySchema, req.query);

  const filter = {};
  if (q.author) filter.author = q.author;
  if (q.genre) filter.genres = q.genre;
  if (q.inStock) filter.inStock = q.inStock === "true";

  const sortField = q.sort.replace(/^-/, "");
  const sortDir = q.sort.startsWith("-") ? -1 : 1;

  const [items, total] = await Promise.all([
    books
      .find(filter)
      .sort({ [sortField]: sortDir, _id: 1 })
      .skip((q.page - 1) * q.limit)
      .limit(q.limit)
      .toArray(),
    books.countDocuments(filter),
  ]);

  res.json({
    data: items.map(serialize),
    page: q.page,
    limit: q.limit,
    total,
    totalPages: Math.ceil(total / q.limit),
  });
});

A few decisions worth explaining:

  • Sort is whitelisted. Clients pick from four options. Letting them pass arbitrary field names invites slow, unindexed sorts.
  • _id is a tiebreaker. Without it, documents with the same title can shuffle between pages.
  • limit is capped at 100. An API that lets clients request a million rows will eventually be asked for a million rows.
  • The count and the page run in parallel with Promise.all, so the endpoint pays for one round trip rather than two sequential ones.

skip/limit pagination is simple and fine for catalogs with modest page counts. For deep pagination or infinite scroll over large collections, range-based (cursor) pagination performs better, as explained in How to Implement Pagination in MongoDB.

Update: PATCH /api/books/:id

Use PATCH for partial updates and $set only the fields provided:

router.patch("/:id", async (req, res) => {
  const _id = toObjectId(req.params.id);
  const changes = parse(bookUpdateSchema, req.body);

  const updated = await books.findOneAndUpdate(
    { _id },
    { $set: { ...changes, updatedAt: new Date() } },
    { returnDocument: "after" },
  );

  if (!updated) {
    throw new HttpError(404, "Book not found");
  }
  res.json(serialize(updated));
});

findOneAndUpdate with returnDocument: "after" gives you the updated document in a single operation. In driver 6.x it returns the document directly, or null if nothing matched.

This is why the update schema is built from the raw field definitions rather than from bookCreateSchema.partial(). If the update schema inherited the create schema's defaults, a request that only sent { "title": "New" } could also reset genres to an empty array and inStock to true, depending on how your Zod version treats defaults inside optional fields. It's worth writing a test that sends a single field and checks nothing else changed.

Delete: DELETE /api/books/:id

router.delete("/:id", async (req, res) => {
  const { deletedCount } = await books.deleteOne({
    _id: toObjectId(req.params.id),
  });
  if (deletedCount === 0) {
    throw new HttpError(404, "Book not found");
  }
  res.status(204).end();
});

204 No Content is the conventional response for a successful delete.

Assembling the App

// src/app.js
import express from "express";
import { router as booksRouter } from "./books/router.js";
import { errorHandler, notFound } from "./errors.js";
import { client } from "./db.js";

export function createApp() {
  const app = express();

  app.disable("x-powered-by");
  app.use(express.json({ limit: "100kb" }));

  app.get("/health", async (req, res) => {
    await client.db("admin").command({ ping: 1 });
    res.json({ status: "ok" });
  });

  app.use("/api/books", booksRouter);

  app.use(notFound);
  app.use(errorHandler);

  return app;
}
// src/index.js
import { createApp } from "./app.js";
import { connectDb, client } from "./db.js";

await connectDb();

const app = createApp();
const port = Number(process.env.PORT ?? 3000);

const server = app.listen(port, (err) => {
  if (err) throw err;
  console.log(`Listening on http://localhost:${port}`);
});

for (const signal of ["SIGINT", "SIGTERM"]) {
  process.on(signal, () => {
    server.close(async () => {
      await client.close();
      process.exit(0);
    });
  });
}

Order matters in app.js: routes first, then the 404 handler, then the error handler last. The JSON body limit protects against huge payloads, and the health check pings the database, so a load balancer can tell the difference between "process running" and "process can actually serve requests."

Trying It Out

Start the server with npm run dev and exercise the endpoints:

curl -s -X POST localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"Dune","author":"Frank Herbert","isbn":"9780441172719","publishedYear":1965,"genres":["sci-fi"]}'
{
  "id": "66fa1b2c3d4e5f6a7b8c9d0e",
  "title": "Dune",
  "author": "Frank Herbert",
  "isbn": "9780441172719",
  "publishedYear": 1965,
  "genres": ["sci-fi"],
  "inStock": true,
  "createdAt": "2026-09-21T06:50:12.114Z",
  "updatedAt": "2026-09-21T06:50:12.114Z"
}

Send the same request again and you get the conflict:

{ "error": "Duplicate value", "details": { "isbn": "9780441172719" } }

Filter and paginate:

curl -s "localhost:3000/api/books?genre=sci-fi&sort=-publishedYear&limit=5"

Send garbage and get a useful error instead of a crash:

curl -s -X POST localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"","isbn":{"$ne":null}}'
{
  "error": "Validation failed",
  "details": [
    {
      "path": ["title"],
      "message": "String must contain at least 1 character(s)"
    },
    { "path": ["author"], "message": "Required" },
    { "path": ["isbn"], "message": "Expected string, received object" }
  ]
}

(The exact wording of Zod messages varies between versions.)

Security Details

A few things beyond validation deserve attention before this goes anywhere public:

  • Query string parsing. Express 5 uses a simple query parser by default, so ?author[$ne]=x arrives as a literal string key rather than a nested object. If you switch to the extended parser, validation becomes your only defense against operator injection in query parameters. Keep validating either way.
  • Least-privilege credentials. The API's database user needs readWrite on the catalog database and nothing more.
  • Rate limiting and authentication. Add middleware such as express-rate-limit and an auth layer before the routers. Write endpoints especially should never be anonymous.
  • CORS. If a browser app on another origin calls the API, configure cors with an explicit origin list instead of *.

Common Mistakes

Creating a MongoClient in each route. Create it once in db.js and import it everywhere.

Passing req.body straight into queries or inserts. Always validate and pick known fields. Unvalidated bodies are how both injection and mass-assignment bugs happen.

Returning 500 for client errors. Invalid IDs, validation failures, and duplicates are the client's problem, so return 400, 404, or 409. Reserve 500 for genuine server faults.

Unbounded list endpoints. Every list route needs a default and maximum limit.

Sorting on arbitrary fields. Whitelist sort options and make sure they're backed by indexes.

Leaking error details. Log the full error server-side and send a generic message to the client.

Conclusion

A solid Express and MongoDB API rests on a few habits: one shared MongoClient, validation at the edge that strips unknown fields and rejects wrong types, a central error handler that maps driver errors to proper HTTP statuses, and list endpoints with whitelisted sorts and capped pagination. Express 5's native async error handling removes a lot of the boilerplate that used to clutter these apps, so the code that remains is mostly your actual business logic.

As a next step, add a second resource (say, authors) using the same router and schema pattern, then write a handful of integration tests against createApp() with a throwaway database. Having the app factory separate from the server makes that easy.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading