Design a REST API with Node.js

Build a Bookstore REST API with Express using interview-grade design patterns.

Introduction

30 Second Summary

Every app you use daily talks to a server through an API. When you check the weather, scroll social media, or place an online order, a REST API handles that request behind the scenes. Knowing how to design a clean, well-structured API is one of the most common topics in software engineering interviews.

In this project, you will build a Bookstore REST API with Node.js and Express that demonstrates every major design pattern interviewers ask about.

What You'll Build

A Bookstore REST API serving books and reviews resources from in-memory data, demonstrating proper resource naming, HTTP method usage, structured error responses, input validation, and query-based filtering.

By the end of this project, you'll have:

  • A running Express 5 API server with 9 endpoints across two resources (books and reviews).
  • An RFC 9457-compliant error handling system that returns structured Problem Details responses with a centralized error handler.
  • Input validation middleware and query-based filtering and search on your books collection.
  • Secret Mission: Add cursor-based pagination to the GET /api/v1/books endpoint.

Are there any prerequisites?

You should be comfortable with basic JavaScript (variables, functions, objects, arrays) and familiar with using a terminal or command line. No prior experience with Express or API design is needed.

Before We Start

Before We Start...

Set Up the Project

Every REST API starts with a working server. Before you can design endpoints, handle errors, or validate input, you need a foundation that can receive and respond to HTTP requests.

In this step, you'll create a new Node.js project, install Express as your web framework, and write a minimal server with a health check endpoint to prove everything is wired up.

In this step, get ready to:
  • Create a project folder and initialize it with npm.
  • Install Express 5 as the web framework.
  • Write a health check endpoint and verify the server runs.
Create the project folder and install Express

You need a dedicated folder for your project and a couple of terminal commands to initialize it. This gives you a clean workspace with Express ready to go.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir bookstore-api
cd bookstore-api
  • Initialize the project with npm by running:
npm init -y

You should see output confirming a package.json file was created.

  • Install Express 5 by running:
npm install express@5

You should see output showing packages were added. This creates a node_modules folder and a package-lock.json file.

  • Confirm your project files exist by running:
ls

You should see node_modules, package-lock.json, and package.json in the output.

Seeing 'directory already exists' or a network error?

If mkdir says the folder already exists, delete it first with rm -rf ~/Desktop/bookstore-api and try again.

If npm install fails with a network error, check your internet connection and retry the command.

Help me troubleshoot my project setup.

Create the server entry point

With Express installed, you need an entry point file that creates the server and defines your first endpoint. This file is where every incoming request arrives.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the bookstore-api folder, and click Open.
  • In the Explorer sidebar on the left, hover over the BOOKSTORE-API heading and click the new file icon.
  • Type server.js and press Enter to create the file.
  • Copy the following code into server.js:
// Import the Express framework
const express = require('express');

// Create the app instance and set the port
const app = express();
const PORT = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Health check endpoint - responds to GET /
app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});

What does this code do?

  • express() creates your application instance. Every route and middleware attaches to this object.
  • express.json() is middleware that automatically parses JSON request bodies. You'll need this when clients send data to create or update books.
  • app.get('/', ...) defines a health check endpoint. This is a standard pattern for confirming your server is alive and reachable.
  • app.listen(PORT, ...) starts the server on port 3000 and logs a confirmation message.
  • Save server.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and move on to verify your server starts.

ⓧ I'd like to double check the full code

Here's the complete server.js file. Make sure yours matches exactly:

const express = require('express');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});
Start the server and verify it works

The moment of truth. Starting the server confirms that Express is installed correctly and your code has no syntax errors.

  • Open VS Code's integrated terminal by clicking Terminal in the top menu, then New Terminal.
  • Start your server by running:
node server.js

You should see this message in your terminal:

Bookstore API running on http://localhost:3000
  • Open a second terminal by clicking Terminal in the top menu, then New Terminal again.
  • Test the health check endpoint by running:
curl http://localhost:3000/

You should see this JSON response:

{"status":"ok","message":"Bookstore API is running"}

What just happened?

Your server received an HTTP GET request at the / path and returned a JSON response with a 200 status code. This is the simplest possible endpoint, but it proves your entire stack is working: Node.js runs the code, Express handles the routing, and the response reaches the client.

  • Switch back to the first terminal (click on it in the terminal panel).
  • Stop the server by pressing Ctrl+C.

Not seeing the expected output?

If you see Cannot find module 'express', make sure you ran npm install express@5 inside the bookstore-api folder. Your terminal's current directory must be the project folder.

If the curl command returns "connection refused", your server is not running. Check the first terminal for error messages and make sure node server.js is still active.

Help me debug my server startup.

Your server is alive and responding to requests. Next up, you'll design the books resource with full CRUD endpoints that follow REST conventions interviewers look for.

Design the Books Resource

Your server is running and responding to a health check. Now it is time to give it something useful to serve.

A REST API organizes data into resources. Each resource is a noun (books, users, orders) with a standard set of operations: list, get, create, update, and delete. In this step, you will build the books resource with full CRUD endpoints, then add filtering so clients can search the collection.

In this step, get ready to:
  • Create an in-memory data store for books.
  • Build CRUD endpoints (GET, POST, PUT, DELETE) following REST naming conventions.
  • Add query parameter filtering for genre, author, and keyword search.
Create the data store and GET endpoints

Before building routes, you need data for them to serve. You will create a small in-memory module that stores books as a JavaScript array and exposes functions to read and modify that array.

  • Create a data folder inside your project directory.
  • Create a file called books.js inside the data folder and add the following code:
// Seed data - three books to start with
let books = [
  { id: '1', title: 'The Hobbit', author: 'J.R.R. Tolkien', genre: 'fantasy', year: 1937 },
  { id: '2', title: 'Dune', author: 'Frank Herbert', genre: 'science-fiction', year: 1965 },
  { id: '3', title: '1984', author: 'George Orwell', genre: 'dystopian', year: 1949 },
];

// Counter for generating unique IDs
let nextId = 4;

// Return all books in the collection
function getAll() {
  return books;
}

// Find a single book by its ID
function getById(id) {
  return books.find(book => book.id === id);
}

// Add a new book and return it with a generated ID
function create(data) {
  const book = { id: String(nextId++), ...data };
  books.push(book);
  return book;
}

// Replace a book's data entirely, return null if not found
function update(id, data) {
  const index = books.findIndex(book => book.id === id);
  if (index === -1) return null;
  books[index] = { id, ...data };
  return books[index];
}

// Remove a book by ID, return false if not found
function remove(id) {
  const index = books.findIndex(book => book.id === id);
  if (index === -1) return false;
  books.splice(index, 1);
  return true;
}

module.exports = { getAll, getById, create, update, remove };

What does this code do?

This module acts as a mini database. The books array holds three seed records so you have data to query immediately.

Each function maps to a database operation: getAll returns every book, getById looks up one record, create adds a new entry with an auto-incremented ID, update replaces a record entirely, and remove deletes one. The route handlers you build next will call these functions instead of touching the array directly.

  • Save data/books.js.

Now you will create the router that exposes this data over HTTP. In Express, a Router is a mini-application that handles its own routes and middleware. You mount it onto a path in your main app.

  • Create a routes folder inside your project directory.
  • Create a file called books.js inside the routes folder and add the following code:
const express = require('express');
const router = express.Router();
const booksData = require('../data/books');

// GET / - List all books
router.get('/', (req, res) => {
  let results = booksData.getAll();
  res.json({ data: results });
});

// GET /:id - Get a single book by ID
router.get('/:id', (req, res) => {
  const book = booksData.getById(req.params.id);
  if (!book) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.json({ data: book });
});

module.exports = router;

What does this code do?

  • express.Router() creates an isolated set of routes you can develop and test independently from the main app.
  • GET / returns all books wrapped in a { data: [...] } envelope. Wrapping responses in an object leaves room to add metadata (like pagination) later without breaking clients.
  • GET /:id returns a single book or a 404 if the ID does not match any record. The 404 status tells the client the resource does not exist.
  • Save routes/books.js.

The router exists but your app does not know about it yet. You need to mount it at a versioned path so clients reach it at /api/v1/books.

  • Open server.js and find this line near the top:
const express = require('express');
  • Add the following line directly below it to import the books router:
const booksRouter = require('./routes/books');
  • Now find the health check route (the app.get('/', ...) block) and add this line below it to mount the router:
app.use('/api/v1/books', booksRouter);

Why version the URL with /api/v1?

Including a version prefix like /v1 in your API path lets you release breaking changes under /v2 without breaking existing clients. This is the most common versioning strategy for public APIs and a pattern interviewers expect to see.

  • Save server.js.
  • If your server is still running from Step 1, stop it by pressing Ctrl+C in the terminal.
  • Restart the server by running:
node server.js
  • Test the list endpoint by running this in a new terminal tab:
curl http://localhost:3000/api/v1/books

You should see a JSON response containing all three seed books wrapped in a data array.

  • Test the single-book endpoint by running:
curl http://localhost:3000/api/v1/books/1

You should see The Hobbit's data. Now test a missing ID:

curl http://localhost:3000/api/v1/books/999

You should see a 404 JSON error response.

Getting an empty response or Cannot GET?

  • Make sure you saved both routes/books.js and server.js before restarting.
  • Check that the require path in server.js is './routes/books' (with the leading dot-slash).
  • Confirm the folder structure is routes/books.js and data/books.js at the same level as server.js.

Why aren't my routes working?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const booksRouter = require('./routes/books');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.use('/api/v1/books', booksRouter);

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});
Add POST, PUT, and DELETE endpoints

Your API can read books. Now you will add the endpoints that let clients create, update, and delete them. Each operation maps to a specific HTTP method with its own expected status code.

  • In routes/books.js, find the line that says module.exports = router; and add the following three route handlers above it:
// POST / - Create a new book (returns 201 Created)
router.post('/', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.create({ title, author, genre, year });
  res.status(201).json({ data: book });
});

// PUT /:id - Replace a book entirely (returns 200 or 404)
router.put('/:id', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.update(req.params.id, { title, author, genre, year });
  if (!book) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.json({ data: book });
});

// DELETE /:id - Remove a book (returns 204 No Content or 404)
router.delete('/:id', (req, res) => {
  const removed = booksData.remove(req.params.id);
  if (!removed) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.status(204).send();
});

What do these endpoints do?

  • POST creates a new resource. It returns 201 Created to signal that something new exists on the server. This is the correct status for successful creation.
  • PUT replaces a resource entirely. It returns 200 with the updated book, or 404 if the ID does not exist. PUT is idempotent, meaning calling it multiple times with the same data produces the same result.
  • DELETE removes a resource. It returns 204 No Content (empty body, because the resource no longer exists) or 404 if it was already gone.
  • Save routes/books.js and restart the server (Ctrl+C, then node server.js).
  • Test creating a book by running:
curl -X POST http://localhost:3000/api/v1/books -H "Content-Type: application/json" -d '{"title":"Neuromancer","author":"William Gibson","genre":"cyberpunk","year":1984}'

You should see a JSON response with the new book and id "4".

  • Test deleting a book by running:
curl -X DELETE http://localhost:3000/api/v1/books/4

You should see an empty response with no error. The book is gone.

POST returning undefined fields?

  • Make sure app.use(express.json()) is in your server.js before the router mount. Without this middleware, Express cannot parse JSON request bodies.
  • Check that your curl command includes the -H "Content-Type: application/json" header.

Why is my POST request body empty?

Add filtering via query parameters

A good API does not force clients to download the entire collection every time. Query parameters let clients filter and search without creating new endpoints. You will add three filters: exact genre match, partial author match, and a keyword search across title and author.

  • In routes/books.js, find the GET / handler. It currently looks like this:
router.get('/', (req, res) => {
  let results = booksData.getAll();
  res.json({ data: results });
});
  • Replace that entire handler with this updated version that includes filtering:
// GET / - List books with optional filtering
router.get('/', (req, res) => {
  let results = booksData.getAll();

  // Filter by exact genre match (case-insensitive)
  if (req.query.genre) {
    results = results.filter(b => b.genre.toLowerCase() === req.query.genre.toLowerCase());
  }
  // Filter by partial author name match
  if (req.query.author) {
    results = results.filter(b => b.author.toLowerCase().includes(req.query.author.toLowerCase()));
  }
  // Search keyword across title and author
  if (req.query.q) {
    const q = req.query.q.toLowerCase();
    results = results.filter(b =>
      b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)
    );
  }

  res.json({ data: results });
});

How does this filtering work?

Each if block checks whether the client passed a specific query parameter. If it did, the code narrows the results array. Multiple filters stack. A request to /api/v1/books?genre=fantasy&q=hobbit first filters to fantasy books, then further narrows to those matching "hobbit" in title or author.

Using let for results is intentional. Each filter reassigns the variable to a smaller subset of the original array.

  • Save routes/books.js and restart the server.
  • Test the genre filter by running:
curl "http://localhost:3000/api/v1/books?genre=fantasy"

You should see only The Hobbit in the response.

  • Test the search filter by running:
curl "http://localhost:3000/api/v1/books?q=orwell"

You should see only 1984 in the response.

Filters not narrowing the results?

  • Make sure you replaced the entire GET / handler, not just appended the filtering code after res.json().
  • Wrap the URL in quotes in your terminal. Without quotes, the ? and & characters can be interpreted by the shell instead of being sent to the server.

Why is my query filtering not working?

✔️ Awesome, I've got everything!

Great. Double check you have saved routes/books.js with all five route handlers and the filtering logic.

ⓧ I'd like to double check the full code

let books = [
  { id: '1', title: 'The Hobbit', author: 'J.R.R. Tolkien', genre: 'fantasy', year: 1937 },
  { id: '2', title: 'Dune', author: 'Frank Herbert', genre: 'science-fiction', year: 1965 },
  { id: '3', title: '1984', author: 'George Orwell', genre: 'dystopian', year: 1949 },
];

let nextId = 4;

function getAll() {
  return books;
}

function getById(id) {
  return books.find(book => book.id === id);
}

function create(data) {
  const book = { id: String(nextId++), ...data };
  books.push(book);
  return book;
}

function update(id, data) {
  const index = books.findIndex(book => book.id === id);
  if (index === -1) return null;
  books[index] = { id, ...data };
  return books[index];
}

function remove(id) {
  const index = books.findIndex(book => book.id === id);
  if (index === -1) return false;
  books.splice(index, 1);
  return true;
}

module.exports = { getAll, getById, create, update, remove };
const express = require('express');
const router = express.Router();
const booksData = require('../data/books');

router.get('/', (req, res) => {
  let results = booksData.getAll();

  if (req.query.genre) {
    results = results.filter(b => b.genre.toLowerCase() === req.query.genre.toLowerCase());
  }
  if (req.query.author) {
    results = results.filter(b => b.author.toLowerCase().includes(req.query.author.toLowerCase()));
  }
  if (req.query.q) {
    const q = req.query.q.toLowerCase();
    results = results.filter(b =>
      b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)
    );
  }

  res.json({ data: results });
});

router.get('/:id', (req, res) => {
  const book = booksData.getById(req.params.id);
  if (!book) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.json({ data: book });
});

router.post('/', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.create({ title, author, genre, year });
  res.status(201).json({ data: book });
});

router.put('/:id', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.update(req.params.id, { title, author, genre, year });
  if (!book) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.json({ data: book });
});

router.delete('/:id', (req, res) => {
  const removed = booksData.remove(req.params.id);
  if (!removed) {
    return res.status(404).json({ error: `Book with id '${req.params.id}' does not exist.` });
  }
  res.status(204).send();
});

module.exports = router;
const express = require('express');
const booksRouter = require('./routes/books');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.use('/api/v1/books', booksRouter);

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});

Your books resource is complete with five endpoints and three query filters. Next up, you will build a centralized error handler using the RFC 9457 standard so every error response follows the same structured format.

Implement RFC 9457 Error Handling

Your books router currently returns errors as ad-hoc JSON like {"error": "..."}. Every API endpoint invents its own error shape, which makes life harder for clients trying to parse failures programmatically.

In this step, you will build a centralized error-handling system based on RFC 9457 (Problem Details for HTTP APIs). This is the IETF standard that major APIs like Stripe and GitHub use to return consistent, machine-readable error responses.

In this step, get ready to:
  • Create an AppError class that carries structured error information.
  • Write centralized error-handling middleware that formats all errors as RFC 9457 JSON.
  • Add a 404 catch-all for unknown routes.
Create the AppError class

A custom error class lets you attach structured metadata (status code, title, detail) to every error your API throws. This single class becomes the source of truth for how errors flow through your application.

  • In your project's root folder (the same level as server.js), create a new folder called middleware.
  • Inside middleware, create a new file called errors.js.
  • Add the AppError class by copying this code into middleware/errors.js:
class AppError extends Error {
  constructor(status, title, detail, errors) {
    super(title);
    this.status = status;
    this.title = title;
    this.detail = detail;
    this.errors = errors || undefined;
  }
}

What does this code do?

  • AppError extends the built-in Error class so it works with standard JavaScript error handling (try/catch, Express error middleware).
  • The constructor takes four arguments that map directly to RFC 9457 fields: status (HTTP status code), title (short summary), detail (human-readable explanation for this occurrence), and errors (optional extension array for validation errors).
  • Calling super(title) sets the standard Error.message property so stack traces remain readable.

You will not see a change yet. The next chunk builds the middleware that catches these errors and formats them as RFC 9457 responses.

Write the error-handling middleware

Express identifies error-handling middleware by its four-parameter signature: (err, req, res, next). Any time you call next(error) in a route handler, Express skips all remaining normal middleware and jumps straight to the first four-parameter function it finds.

  • In middleware/errors.js, add the following two functions below the AppError class:
function notFoundHandler(req, res, next) {
  const error = new AppError(
    404,
    'Not Found',
    `The path ${req.originalUrl} does not exist on this server.`
  );
  next(error);
}

function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const body = {
    type: 'about:blank',
    title: err.title || 'Internal Server Error',
    status: status,
    detail: err.detail || err.message || 'An unexpected error occurred.',
    instance: req.originalUrl,
  };

  if (err.errors) {
    body.errors = err.errors;
  }

  res.status(status).set('Content-Type', 'application/problem+json').json(body);
}

What does this code do?

These two functions work together to handle every unmatched or errored request in your API.

  • notFoundHandler is regular middleware (three parameters). It sits after all your route definitions. If a request reaches this point, no route matched it, so it creates a 404 AppError and passes it along.
  • errorHandler is error middleware (four parameters). It catches every error passed via next(error) and formats it as an RFC 9457 Problem Details JSON response.
  • type is set to about:blank because RFC 9457 says this is the default when no custom problem type URI is defined.
  • instance identifies the specific request that triggered the error using req.originalUrl.
  • The Content-Type header is set to application/problem+json as required by the RFC 9457 specification.

Why RFC 9457 over ad-hoc errors?

Interviewers specifically ask about consistent error handling. RFC 9457 is the IETF standard adopted by major production APIs. Teaching it gives you a concrete name to reference: "I use RFC 9457 Problem Details for structured error responses."

The standard also allows extension members beyond the core five fields. Your errors array is an extension member that you will use for detailed validation failures in the next step.

Having a single centralized handler also means every error in your API looks the same to clients. No more guessing whether a failure returns {"error": "..."} or {"message": "..."}.

Tell me more about RFC 9457 and why production APIs use it

  • Still in middleware/errors.js, add the module exports at the bottom of the file:
module.exports = { AppError, notFoundHandler, errorHandler };
  • Save middleware/errors.js.

Now update routes/books.js to use the new AppError class instead of inline error responses.

  • Switch to routes/books.js and replace the entire file contents with this updated version:
const express = require('express');
const router = express.Router();
const booksData = require('../data/books');
const { AppError } = require('../middleware/errors');

router.get('/', (req, res) => {
  let results = booksData.getAll();

  if (req.query.genre) {
    results = results.filter(b => b.genre.toLowerCase() === req.query.genre.toLowerCase());
  }
  if (req.query.author) {
    results = results.filter(b => b.author.toLowerCase().includes(req.query.author.toLowerCase()));
  }
  if (req.query.q) {
    const q = req.query.q.toLowerCase();
    results = results.filter(b =>
      b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)
    );
  }

  res.json({ data: results });
});

router.get('/:id', (req, res, next) => {
  const book = booksData.getById(req.params.id);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.post('/', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.create({ title, author, genre, year });
  res.status(201).json({ data: book });
});

router.put('/:id', (req, res, next) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.update(req.params.id, { title, author, genre, year });
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.delete('/:id', (req, res, next) => {
  const removed = booksData.remove(req.params.id);
  if (!removed) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.status(204).send();
});

module.exports = router;

What changed in this file?

  • A new import at the top: const { AppError } = require('../middleware/errors').
  • The GET /:id, PUT /:id, and DELETE /:id handlers now accept next as a third parameter in their callback signature.
  • Instead of res.status(404).json({error: ...}), each 404 case now calls next(new AppError(404, 'Not Found', ...)) which hands the error to the centralized errorHandler.
  • Save routes/books.js.
Add a 404 catch-all for unknown routes

When a request hits a URL that no route handles (like GET /api/v1/bananas), Express needs a way to respond with a structured 404 instead of a blank response or HTML error page. You already wrote the notFoundHandler function. Now you need to wire it into the server.

  • Switch to server.js and replace the entire file contents with this updated version:
const express = require('express');
const booksRouter = require('./routes/books');
const { notFoundHandler, errorHandler } = require('./middleware/errors');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.use('/api/v1/books', booksRouter);

app.use(notFoundHandler);
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});

What changed in this file?

  • A new import at the top destructures both notFoundHandler and errorHandler from ./middleware/errors.
  • app.use(notFoundHandler) is registered AFTER all route definitions so it only fires when no route matched.
  • app.use(errorHandler) is registered last because Express error middleware must be the final handler in the chain.
  • Save server.js.
  • If your server is still running from earlier, stop it with Ctrl+C in the terminal.
  • Start the server again by running:
node server.js
  • Test the error handling by requesting a book that does not exist:
curl -s http://localhost:3000/api/v1/books/999

You should see a structured RFC 9457 response with type, title, status, detail, and instance fields instead of the old ad-hoc format.

  • Test the 404 catch-all by hitting a route that does not exist:
curl -s http://localhost:3000/api/v1/bananas

You should see a Problem Details response with a detail message mentioning the path /api/v1/bananas.

Seeing a module not found error?

  • Check that you created the middleware folder in your project root (at the same level as server.js), not inside another folder.
  • Verify the filename is exactly errors.js (not error.js without the s).
  • Make sure routes/books.js uses the path ../middleware/errors (going up one directory from the routes folder).

Still stuck? Help me debug a module not found error in my Express error middleware setup

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full middleware/errors.js:

class AppError extends Error {
  constructor(status, title, detail, errors) {
    super(title);
    this.status = status;
    this.title = title;
    this.detail = detail;
    this.errors = errors || undefined;
  }
}

function notFoundHandler(req, res, next) {
  const error = new AppError(
    404,
    'Not Found',
    `The path ${req.originalUrl} does not exist on this server.`
  );
  next(error);
}

function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const body = {
    type: 'about:blank',
    title: err.title || 'Internal Server Error',
    status: status,
    detail: err.detail || err.message || 'An unexpected error occurred.',
    instance: req.originalUrl,
  };

  if (err.errors) {
    body.errors = err.errors;
  }

  res.status(status).set('Content-Type', 'application/problem+json').json(body);
}

module.exports = { AppError, notFoundHandler, errorHandler };

Here is the full routes/books.js:

const express = require('express');
const router = express.Router();
const booksData = require('../data/books');
const { AppError } = require('../middleware/errors');

router.get('/', (req, res) => {
  let results = booksData.getAll();

  if (req.query.genre) {
    results = results.filter(b => b.genre.toLowerCase() === req.query.genre.toLowerCase());
  }
  if (req.query.author) {
    results = results.filter(b => b.author.toLowerCase().includes(req.query.author.toLowerCase()));
  }
  if (req.query.q) {
    const q = req.query.q.toLowerCase();
    results = results.filter(b =>
      b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)
    );
  }

  res.json({ data: results });
});

router.get('/:id', (req, res, next) => {
  const book = booksData.getById(req.params.id);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.post('/', (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.create({ title, author, genre, year });
  res.status(201).json({ data: book });
});

router.put('/:id', (req, res, next) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.update(req.params.id, { title, author, genre, year });
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.delete('/:id', (req, res, next) => {
  const removed = booksData.remove(req.params.id);
  if (!removed) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.status(204).send();
});

module.exports = router;

Here is the full server.js:

const express = require('express');
const booksRouter = require('./routes/books');
const { notFoundHandler, errorHandler } = require('./middleware/errors');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.use('/api/v1/books', booksRouter);

app.use(notFoundHandler);
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});

Your API now returns structured, consistent error responses for every failure case. Next up, you will add input validation middleware that uses this same error system to reject malformed requests with specific field-level errors.

Add Input Validation

Your centralized error handler from the last step catches problems and returns structured RFC 9457 responses. But right now, your POST and PUT endpoints accept absolutely anything. A client could create a book with an empty title or a year of 99999.

In this step, you'll build validation middleware that rejects bad input before it reaches your route handlers. Invalid requests get a 400 response with specific field-level error messages that tell the client exactly what went wrong.

In this step, get ready to:
  • Create a validateBook middleware function that checks required fields and data types.
  • Apply the middleware to your POST and PUT routes.
  • Test that invalid data returns structured error details in the response.
Create the validation middleware

Express middleware functions sit between the incoming request and your route handler. A validation middleware inspects the request body, collects any problems, and either passes control to the route handler or short-circuits with an error.

  • Create a new file called validate.js inside your middleware folder.
  • Paste the following code into middleware/validate.js:
const { AppError } = require('./errors');

function validateBook(req, res, next) {
  const { title, author, genre, year } = req.body;
  const errors = [];

  // Check that title is a non-empty string
  if (!title || typeof title !== 'string' || title.trim() === '') {
    errors.push({ field: 'title', message: 'Title is required and must be a non-empty string.' });
  }
  // Check that author is a non-empty string
  if (!author || typeof author !== 'string' || author.trim() === '') {
    errors.push({ field: 'author', message: 'Author is required and must be a non-empty string.' });
  }
  // Check that genre is a non-empty string
  if (!genre || typeof genre !== 'string' || genre.trim() === '') {
    errors.push({ field: 'genre', message: 'Genre is required and must be a non-empty string.' });
  }
  // Check that year exists and is a valid number in range
  if (year === undefined || year === null) {
    errors.push({ field: 'year', message: 'Year is required.' });
  } else if (typeof year !== 'number' || year < 1000 || year > new Date().getFullYear()) {
    errors.push({ field: 'year', message: `Year must be a number between 1000 and ${new Date().getFullYear()}.` });
  }

  // If any errors collected, pass a 400 AppError to the error handler
  if (errors.length > 0) {
    return next(new AppError(400, 'Validation Error', 'One or more fields failed validation.', errors));
  }

  // All checks passed, continue to the route handler
  next();
}

module.exports = { validateBook };

What does this code do?

The validateBook function destructures the request body and checks each field one by one, collecting error objects into an array.

  • For title, author, and genre: the check rejects falsy values, non-string types, and strings that are only whitespace.
  • For year: it first checks the field exists, then validates it is a number between 1000 and the current year.
  • If any errors are collected, the function calls next(new AppError(400, ...)) which routes the error to your centralized error handler. The errors array becomes an RFC 9457 extension member in the response.

Why collect all errors instead of failing on the first one?

Returning all validation errors at once saves the client from fixing one field, resubmitting, hitting the next error, and repeating. This is a REST design best practice that interviewers look for.

  • Save middleware/validate.js.
  • Confirm the file exists by running:
ls middleware/

You should see both errors.js and validate.js listed.

Only seeing errors.js?

  • Make sure you created the file inside the middleware folder, not in the project root.
  • Check the filename is exactly validate.js (no typos, no capital letters).

Still stuck? Ask for help.

Apply validation to the books routes

The middleware exists, but Express does not know to use it yet. You need to import it in your books router and slot it into the POST and PUT route definitions.

  • In routes/books.js, find the imports at the top of the file. Locate this line:
const { AppError } = require('../middleware/errors');
  • Add this new import directly below it:
const { validateBook } = require('../middleware/validate');
  • Now find the POST route. It currently looks like this:
router.post('/', (req, res) => {
  • Insert validateBook between the path and the handler function, so the line becomes:
router.post('/', validateBook, (req, res) => {
  • Find the PUT route. It currently looks like this:
router.put('/:id', (req, res, next) => {
  • Insert validateBook the same way:
router.put('/:id', validateBook, (req, res, next) => {

How does Express middleware chaining work?

When you pass multiple arguments to a route like router.post('/', validateBook, handler), Express runs them left to right. If validateBook calls next() with no arguments, the request continues to the handler. If it calls next(error), Express skips straight to the error-handling middleware.

  • Save routes/books.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before testing.

ⓧ I'd like to double check the full code

Here is the complete routes/books.js after your edits:

const express = require('express');
const router = express.Router();
const booksData = require('../data/books');
const { AppError } = require('../middleware/errors');
const { validateBook } = require('../middleware/validate');

router.get('/', (req, res) => {
  let results = booksData.getAll();

  if (req.query.genre) {
    results = results.filter(b => b.genre.toLowerCase() === req.query.genre.toLowerCase());
  }
  if (req.query.author) {
    results = results.filter(b => b.author.toLowerCase().includes(req.query.author.toLowerCase()));
  }
  if (req.query.q) {
    const q = req.query.q.toLowerCase();
    results = results.filter(b =>
      b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)
    );
  }

  res.json({ data: results });
});

router.get('/:id', (req, res, next) => {
  const book = booksData.getById(req.params.id);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.post('/', validateBook, (req, res) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.create({ title, author, genre, year });
  res.status(201).json({ data: book });
});

router.put('/:id', validateBook, (req, res, next) => {
  const { title, author, genre, year } = req.body;
  const book = booksData.update(req.params.id, { title, author, genre, year });
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.json({ data: book });
});

router.delete('/:id', (req, res, next) => {
  const removed = booksData.remove(req.params.id);
  if (!removed) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.id}' does not exist.`));
  }
  res.status(204).send();
});

module.exports = router;
Test validation with an invalid request
  • Stop your running server with Ctrl+C in the terminal, then restart it by running:
node server.js
  • Send a POST request with an empty JSON body to trigger all four validation errors:
curl -s -X POST http://localhost:3000/api/v1/books -H "Content-Type: application/json" -d '{}' | python3 -m json.tool

You should see a 400 response with an errors array containing four objects, one for each missing field (title, author, genre, year).

  • Now test with a bad year value to see the type validation in action:
curl -s -X POST http://localhost:3000/api/v1/books -H "Content-Type: application/json" -d '{"title": "Test", "author": "Me", "genre": "fiction", "year": 99999}' | python3 -m json.tool

This time you should see a single error in the array for the year field, because 99999 exceeds the current year.

Seeing a 201 instead of a 400?

  • Check that you added validateBook between the route path and the handler function in the POST route. The middleware must be a separate argument.
  • Confirm your import line uses the correct relative path: ../middleware/validate (with the ../ prefix since you're inside the routes folder).
  • Make sure you restarted the server after saving your changes.

Help me debug why my validation middleware isn't running.

Your API now rejects bad data with clear, structured error messages. Next up, you'll add a nested reviews resource under books to demonstrate hierarchical REST design.

Design the Reviews Sub-Resource

Your books resource handles the core collection, but real-world APIs rarely have just one resource. A bookstore needs reviews, and those reviews belong to specific books.

In REST design, this parent-child relationship is modeled as a nested resource (also called a sub-resource). You'll build reviews under books, with URLs like /api/v1/books/:bookId/reviews. This makes the ownership relationship visible in the URL itself.

In this step, get ready to:
  • Create the reviews data store with seed data.
  • Build GET, POST, and DELETE endpoints for reviews as a nested resource.
  • Verify parent book validation returns 404 Problem Details for missing books.
Create the reviews data store and list endpoint

Just like your books resource has a data layer, reviews need their own in-memory store. Each review belongs to a book through a bookId field that links it to its parent.

  • Create a new file called data/reviews.js and add the following code:
let reviews = [
  { id: '1', bookId: '1', reviewer: 'Alice', rating: 5, comment: 'A timeless classic!' },
  { id: '2', bookId: '1', reviewer: 'Bob', rating: 4, comment: 'Great world-building.' },
  { id: '3', bookId: '2', reviewer: 'Charlie', rating: 5, comment: 'Mind-blowing sci-fi.' },
];

let nextId = 4;

function getByBookId(bookId) {
  return reviews.filter(review => review.bookId === bookId);
}

function create(bookId, data) {
  const review = { id: String(nextId++), bookId, ...data };
  reviews.push(review);
  return review;
}

function remove(bookId, reviewId) {
  const index = reviews.findIndex(r => r.id === reviewId && r.bookId === bookId);
  if (index === -1) return false;
  reviews.splice(index, 1);
  return true;
}

module.exports = { getByBookId, create, remove };

What does this code do?

This module follows the same pattern as data/books.js but with a key difference. Every function takes a bookId parameter to scope operations to a single parent book.

  • getByBookId(bookId) filters reviews to only those matching the parent book.
  • create(bookId, data) attaches the bookId to each new review, establishing the relationship.
  • remove(bookId, reviewId) checks both IDs to ensure you can only delete a review that belongs to the specified book.
  • Save data/reviews.js.

You won't see a change yet. The next chunk creates the router that uses this data.

Now create the reviews router. A critical design rule for nested resources: always verify the parent exists before returning child data. If someone requests reviews for a book that doesn't exist, you should return a 404, not an empty array.

  • Create a new file called routes/reviews.js and add the following code:
const express = require('express');
const router = express.Router();
const booksData = require('../data/books');
const reviewsData = require('../data/reviews');
const { AppError } = require('../middleware/errors');

// GET all reviews for a specific book
router.get('/:bookId/reviews', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }
  const reviews = reviewsData.getByBookId(req.params.bookId);
  res.json({ data: reviews });
});

module.exports = router;

What does this code do?

The GET handler first checks whether the parent book exists using booksData.getById(). If the book doesn't exist, it passes an AppError to the error handler. Only if the book is valid does it return the reviews.

This parent-check pattern prevents misleading responses. Without it, requesting reviews for book ID 999 would return an empty array, making it look like the book has no reviews when in reality the book doesn't exist.

Why nest reviews under books?

The URL /api/v1/books/:bookId/reviews tells clients that reviews only exist in the context of a book. This is clearer than a flat /api/v1/reviews?bookId=1 approach because the ownership is encoded in the path itself.

Interviewers look for this pattern when asking about resource relationships. It signals that you understand how URL hierarchy communicates data ownership.

Before you can test the GET endpoint, the router needs to be mounted in server.js.

  • In server.js, find this line near the top:
const booksRouter = require('./routes/books');
  • Add this line directly below it:
const reviewsRouter = require('./routes/reviews');
  • Now find this line further down:
app.use('/api/v1/books', booksRouter);
  • Add this line directly below it:
app.use('/api/v1/books', reviewsRouter);

Why the same mount path?

Both routers are mounted at /api/v1/books because the reviews router's routes start with /:bookId/reviews. Express combines the mount path with the route path, producing the full URL /api/v1/books/:bookId/reviews.

  • Save server.js.
  • Stop your running server with Ctrl+C, then restart it by running:
node server.js
  • Test the GET reviews endpoint by running this in a new terminal:
curl http://localhost:3000/api/v1/books/1/reviews

You should see a JSON response with two reviews for The Hobbit (Alice's and Bob's reviews).

Seeing a 404 or empty response?

  • Make sure both data/reviews.js and routes/reviews.js are saved.
  • Check that server.js imports reviewsRouter and mounts it with app.use('/api/v1/books', reviewsRouter).
  • Confirm you restarted the server after saving your changes.

Why is my reviews endpoint returning a 404?

Add the POST endpoint with validation

The POST endpoint lets clients create new reviews. It needs inline validation for the reviewer and rating fields, using the same AppError pattern you built earlier.

  • In routes/reviews.js, find the line that says module.exports = router; and add the following code above it:
// POST a new review for a specific book
router.post('/:bookId/reviews', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }

  const { reviewer, rating, comment } = req.body;
  const errors = [];

  if (!reviewer || typeof reviewer !== 'string' || reviewer.trim() === '') {
    errors.push({ field: 'reviewer', message: 'Reviewer name is required.' });
  }
  if (rating === undefined || typeof rating !== 'number' || rating < 1 || rating > 5) {
    errors.push({ field: 'rating', message: 'Rating must be a number between 1 and 5.' });
  }

  if (errors.length > 0) {
    return next(new AppError(400, 'Validation Error', 'One or more fields failed validation.', errors));
  }

  const review = reviewsData.create(req.params.bookId, { reviewer, rating, comment: comment || '' });
  res.status(201).json({ data: review });
});

What does this code do?

The POST handler follows a three-phase pattern: verify the parent, validate the input, then create the resource.

  • First, it checks the parent book exists (same pattern as the GET handler).
  • Then it validates reviewer (required string) and rating (number between 1 and 5). The comment field is optional and defaults to an empty string.
  • If validation passes, it creates the review and returns 201 (Created).
  • Save routes/reviews.js.
  • Restart your server, then test the POST endpoint by running:
curl -X POST http://localhost:3000/api/v1/books/2/reviews -H "Content-Type: application/json" -d '{"reviewer": "Diana", "rating": 4, "comment": "Fascinating universe."}'

You should see a 201 response with the new review object, including an auto-generated id and bookId of "2".

Getting a 404 when posting?

  • Make sure the URL uses a valid book ID (1, 2, or 3 from your seed data).
  • Check that the Content-Type: application/json header is included in the curl command.

Help me debug my POST reviews endpoint.

Add the DELETE endpoint and verify parent book checks

The DELETE endpoint removes a specific review. It needs to verify both that the parent book exists and that the review belongs to that book.

  • In routes/reviews.js, add the following code above module.exports = router; (below the POST handler you just added):
// DELETE a specific review from a specific book
router.delete('/:bookId/reviews/:reviewId', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }

  const removed = reviewsData.remove(req.params.bookId, req.params.reviewId);
  if (!removed) {
    return next(new AppError(404, 'Not Found', `Review with id '${req.params.reviewId}' does not exist for this book.`));
  }
  res.status(204).send();
});

What does this code do?

The DELETE handler has two layers of 404 protection. First it checks the parent book, then it checks that the review actually belongs to that book.

On success it returns 204 (No Content) with an empty body. This is the standard status code for successful deletions where there's nothing meaningful to return.

  • Save routes/reviews.js.
  • Restart your server, then test the DELETE endpoint by running:
curl -X DELETE http://localhost:3000/api/v1/books/1/reviews/1 -w "\nHTTP Status: %{http_code}\n"

You should see an empty response body with HTTP Status: 204.

  • Now verify the parent book check works by requesting reviews for a non-existent book:
curl http://localhost:3000/api/v1/books/999/reviews

You should see a Problem Details JSON response with status 404 and a detail message saying book 999 does not exist. This confirms your parent validation is working across all three endpoints.

DELETE returning 404 unexpectedly?

  • If you already deleted review 1 in a previous test, try review ID 2 instead (/api/v1/books/1/reviews/2). In-memory data resets when you restart the server.
  • Check that the review's bookId matches the URL's :bookId. Review 1 belongs to book 1, so /api/v1/books/2/reviews/1 will return 404.

Why is my DELETE returning 404 when the review exists?

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: data/reviews.js, routes/reviews.js, and server.js.

ⓧ I'd like to double check the full code

let reviews = [
  { id: '1', bookId: '1', reviewer: 'Alice', rating: 5, comment: 'A timeless classic!' },
  { id: '2', bookId: '1', reviewer: 'Bob', rating: 4, comment: 'Great world-building.' },
  { id: '3', bookId: '2', reviewer: 'Charlie', rating: 5, comment: 'Mind-blowing sci-fi.' },
];

let nextId = 4;

function getByBookId(bookId) {
  return reviews.filter(review => review.bookId === bookId);
}

function create(bookId, data) {
  const review = { id: String(nextId++), bookId, ...data };
  reviews.push(review);
  return review;
}

function remove(bookId, reviewId) {
  const index = reviews.findIndex(r => r.id === reviewId && r.bookId === bookId);
  if (index === -1) return false;
  reviews.splice(index, 1);
  return true;
}

module.exports = { getByBookId, create, remove };
const express = require('express');
const router = express.Router();
const booksData = require('../data/books');
const reviewsData = require('../data/reviews');
const { AppError } = require('../middleware/errors');

router.get('/:bookId/reviews', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }
  const reviews = reviewsData.getByBookId(req.params.bookId);
  res.json({ data: reviews });
});

router.post('/:bookId/reviews', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }

  const { reviewer, rating, comment } = req.body;
  const errors = [];

  if (!reviewer || typeof reviewer !== 'string' || reviewer.trim() === '') {
    errors.push({ field: 'reviewer', message: 'Reviewer name is required.' });
  }
  if (rating === undefined || typeof rating !== 'number' || rating < 1 || rating > 5) {
    errors.push({ field: 'rating', message: 'Rating must be a number between 1 and 5.' });
  }

  if (errors.length > 0) {
    return next(new AppError(400, 'Validation Error', 'One or more fields failed validation.', errors));
  }

  const review = reviewsData.create(req.params.bookId, { reviewer, rating, comment: comment || '' });
  res.status(201).json({ data: review });
});

router.delete('/:bookId/reviews/:reviewId', (req, res, next) => {
  const book = booksData.getById(req.params.bookId);
  if (!book) {
    return next(new AppError(404, 'Not Found', `Book with id '${req.params.bookId}' does not exist.`));
  }

  const removed = reviewsData.remove(req.params.bookId, req.params.reviewId);
  if (!removed) {
    return next(new AppError(404, 'Not Found', `Review with id '${req.params.reviewId}' does not exist for this book.`));
  }
  res.status(204).send();
});

module.exports = router;
const express = require('express');
const booksRouter = require('./routes/books');
const reviewsRouter = require('./routes/reviews');
const { notFoundHandler, errorHandler } = require('./middleware/errors');

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'Bookstore API is running' });
});

app.use('/api/v1/books', booksRouter);
app.use('/api/v1/books', reviewsRouter);

app.use(notFoundHandler);
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Bookstore API running on http://localhost:${PORT}`);
});

Your API now has 9 working endpoints across two resources, with nested URL structures that communicate ownership. This is exactly the kind of design interviewers look for when they ask about resource relationships. Next up, you'll extend this into a Secret Mission with cursor-based pagination.

Secret mission

Add Cursor-Based Pagination

Implement cursor-based pagination on the GET /api/v1/books endpoint. This is a common interview deep-dive topic that shows you understand scalable API patterns used by Stripe, GitHub, and other production APIs.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.

Resources you used:

  • The Node.js server process running on port 3000.
  • The project folder on your Desktop (contains all source files and node_modules).

Keep everything running

No action needed. Choose this if you're still actively experimenting with your API or want to add more endpoints.

  • Your server continues running on port 3000 until you stop it or close the terminal.
  • Your project folder stays on your Desktop with all source files and dependencies intact.

Pause - I'll come back to this later

Stop the running server to free up the port, but keep your project files for later.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server process.
  • Your project folder remains on your Desktop. When you're ready to resume, navigate back to the folder and run node server.js again.

Delete - I don't want to use this again

Remove all project files and stop the server process entirely.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server process.
  • Delete the project folder by running the command for your operating system.

macOS

  • Run this command to delete the project folder:
cd ~/Desktop && rm -rf bookstore-api

Windows

  • Run this command to delete the project folder:
cd ~/Desktop; Remove-Item -Recurse -Force bookstore-api
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see the bookstore-api folder in the output.

Nice Work!

Nice Work!

You just built a complete Bookstore REST API with Express 5 that demonstrates every design pattern interviewers ask about. You can now walk through your code and explain why each decision was made.

You've learned how to:

  • Design a RESTful API with proper resource naming (/api/v1/books, /api/v1/books/:id/reviews), correct HTTP method semantics, and structured filtering and search via query parameters.
  • Implement RFC 9457 Problem Details error responses with a centralized error handler, giving you a concrete industry standard to reference in interviews.
  • Build input validation middleware that returns structured, machine-readable error details in an errors extension array.
  • Secret Mission: Added cursor-based pagination with opaque Base64 cursors and a response envelope containing nextCursor and hasMore fields.

Ready to quiz yourself?