Build a Bookshelf REST API with Express

Build a full CRUD REST API with Express, middleware, and input validation.

Introduction

30 Second Summary

Every time you check the weather, scroll through social media, or order food online, there is an invisible layer of code handling that request. That layer is a REST API.

In this project, you will build a REST API from scratch using Node.js and Express. Your API manages a personal bookshelf, supporting full create, read, update, and delete operations through HTTP requests.

What You'll Build

A Personal Bookshelf API built with Node.js and Express that performs full CRUD operations on a book collection, tested entirely inside VS Code.

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

  • A running Express server with five RESTful endpoints for creating, reading, updating, and deleting books.
  • A custom middleware that logs every incoming request with a timestamp.
  • Input validation and a centralized error handler that return clear JSON responses.
  • Secret Mission: Add search and filtering to your bookshelf using query parameters.

Do I need any experience before starting?

This project assumes basic JavaScript knowledge, including variables, functions, arrays, and objects. VS Code and Node.js should already be installed on your computer.

Set Up Your Project

Every REST API starts with a solid foundation. Before you can build endpoints that manage your book collection, you need a project directory, a package manager setup, and the right dependencies installed.

In this step, you'll create the bookshelf-api project folder, install Express, and create the starter files you'll work with throughout the project.

In this step, get ready to:
  • Create and initialize the bookshelf-api project folder.
  • Install Express as a project dependency.
  • Create the starter app.js and requests.http files.
Create the project directory and install Express

You need a dedicated folder on your Desktop for this project. You'll create it from the terminal, then open it in VS Code.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir bookshelf-api
  • Confirm the folder was created by running:
ls

You should see bookshelf-api listed in the output.

Now open this folder in VS Code.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the bookshelf-api folder, then click Open.

Seeing a trust prompt?

VS Code may show a banner or dialog asking whether you trust the authors of the files in this folder. Since you just created it yourself, click the trust option to continue with full functionality.

  • Open the integrated terminal by pressing Ctrl+` (the backtick key above Tab).

The terminal should open at the bottom of VS Code, already inside the bookshelf-api folder.

  • Initialize your project by running this command:
npm init -y

What does this command do?

npm init -y creates a package.json file with default settings. This file tracks your project's name, version, and dependencies.

The -y flag accepts all the defaults automatically so you don't have to answer each prompt one by one.

You should see a JSON output in the terminal showing your new package.json configuration.

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

What does this command do?

npm install express@5 downloads Express version 5 and saves it as a dependency in your package.json.

It also creates a node_modules folder where the actual package files live. You won't edit anything in node_modules directly.

  • Check the **Explorer** sidebar on the left. You should see package.json, package-lock.json, and a node_modules folder in your project.
  • Click on package.json in the Explorer to open it. Confirm that express appears under dependencies.

npm command not found?

This means Node.js may not be installed correctly. Close VS Code, install Node.js from nodejs.org, then reopen VS Code and try again.

Why is npm not working in my VS Code terminal?

Get to know the REST Client extension

You already have the REST Client extension installed in VS Code. This extension lets you send HTTP requests directly from the editor without switching to a separate tool.

What is the REST Client extension?

REST Client lets you write HTTP requests inside .http files and send them with a single click. Each response appears right inside VS Code.

You'll create your first .http test file in the next substep and use it throughout the project to test your API endpoints.

Create the project files

Your project needs two files. app.js is where your server code will live. requests.http is where you'll write test requests for the REST Client extension.

  • In the Explorer sidebar, hover over the BOOKSHELF-API heading and click the New File icon (the page icon with a plus sign).
  • Type app.js and press Enter.
  • Click the New File icon again.
  • Type requests.http and press Enter.
  • Click on app.js in the Explorer to open it, then add this code:
console.log("Setup complete");

What does this code do?

This is a quick test to confirm Node.js can run your project file. You'll replace this line with your Express server code in the next step.

  • Save app.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Run the file by entering this command in the terminal:
node app.js

You should see Setup complete printed in the terminal.

Not seeing Setup complete?

Make sure you saved app.js before running the command. Unsaved changes won't be picked up by Node.js.

Check that your terminal is inside the bookshelf-api folder. The terminal prompt should show bookshelf-api as the current directory.

Why is my node app.js command not producing output?

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and move on to the next section.

ⓧ I'd like to double check the full code

Here is the complete app.js file at this point. Your file should match this exactly.

console.log("Setup complete");

Your requests.http file should exist but be empty for now.

Your project is set up and ready to go. Next up, you'll create your Express server and add your first API routes to retrieve books.

Create Your Express Server with GET Routes

Your project folder is set up, your dependencies are installed, and you have a blank canvas to work with. Now it's time to bring your REST API to life.

Every API needs a server that listens for incoming requests and sends back responses. In this step, you'll build that server using Express, add some starter book data, and create your first GET routes so anyone can retrieve books from your shelf.

In this step, get ready to:
  • Set up an Express server with a starter book collection.
  • Build GET routes that return book data as JSON.
  • Test your routes using the REST Client extension and a browser.
Set up the Express server with a starter book collection

Before your API can handle any requests, it needs a foundation. You'll set up Express, tell it how to read incoming JSON data, and create a small collection of books to work with.

  • In app.js, select all the existing code and delete it.
  • Add the following code to initialize the Express server:
const express = require('express');
const app = express();
const port = 3000;

// Built-in middleware for parsing JSON request bodies
app.use(express.json());

What does this code do?

These lines are the starting point for every Express application.

  • require('express') loads the Express library into your file.
  • express() creates an app instance that you'll attach routes and middleware to.
  • express.json() is built-in middleware that automatically parses JSON request bodies. This is what lets your API accept JSON data from clients.
  • You won't see any changes yet. Add the following code below the app.use(express.json()) line to create your book data and start the server:
// In-memory data store
let books = [
  { id: 1, title: '1984', author: 'George Orwell', year: 1949 },
  { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: 3, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }
];

let nextId = 4;

app.listen(port, () => {
  console.log(`Bookshelf API running at http://localhost:${port}`);
});

What does this code do?

This sets up the data your API will manage and starts the server.

  • books is an array of three starter books, each with an id, title, author, and year. The data lives in memory, so it resets every time you restart the server.
  • nextId tracks the next available ID for when you create new books later.
  • app.listen() tells Express to start accepting requests on port 3000.
  • Save app.js.
  • Open the VS Code terminal by pressing Ctrl+` (backtick key, located above Tab).
  • Start the server by running:
node app.js
  • You should see Bookshelf API running at http://localhost:3000 in the terminal.

Don't see the message?

  • Check for red error text in the terminal. A SyntaxError usually means a missing comma, bracket, or parenthesis in your code.
  • Make sure you deleted the original console.log("Setup complete") line before adding the new code.
  • help me debug my Express server startup
Add GET routes to retrieve books

Your server is running, but it doesn't know how to respond to any requests yet. Routes tell Express what to do when a client sends a request to a specific URL using a specific HTTP method.

  • In app.js, find the app.listen(port, () => { line.
  • Add the following two GET routes above it:
// GET all books
app.get('/api/books', (req, res) => {
  res.json(books);
});

// GET a single book by ID
app.get('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const book = books.find(b => b.id === id);

  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }

  res.json(book);
});

What do these routes do?

Each route handles a different type of GET request.

  • GET /api/books returns the entire books array as a JSON response. This is your "list all" endpoint.
  • GET /api/books/:id uses a route parameter (:id) to find a single book. Express puts the value from the URL into req.params.id.
  • If no book matches the ID, the route returns a 404 status code with an error message. This tells the client the resource doesn't exist.
  • Save app.js.
  • In the terminal, press Ctrl+C to stop the running server.
  • Restart the server by running:
node app.js
  • Open your browser and visit http://localhost:3000/api/books.
  • You should see a JSON array of three books displayed in the browser.

Seeing an empty page or an error?

  • Make sure your GET routes are placed above the app.listen() call in app.js. Routes defined after app.listen() still work, but placing them before keeps your code organized and avoids confusion.
  • Confirm the terminal shows Bookshelf API running at http://localhost:3000 with no errors. If not, stop the server with Ctrl+C and re-run node app.js.
  • help me figure out why my Express GET route isn't returning data

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and the server is running.

ⓧ I'd like to double check the full code

Here is the full app.js file up to this point:

const express = require('express');
const app = express();
const port = 3000;

app.use(express.json());

let books = [
  { id: 1, title: '1984', author: 'George Orwell', year: 1949 },
  { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: 3, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }
];

let nextId = 4;

app.get('/api/books', (req, res) => {
  res.json(books);
});

app.get('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const book = books.find(b => b.id === id);

  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }

  res.json(book);
});

app.listen(port, () => {
  console.log(`Bookshelf API running at http://localhost:${port}`);
});
Write your first test requests in the .http file

Visiting URLs in the browser works for GET requests, but your API will soon support POST, PUT, and DELETE too. The REST Client extension lets you test all of these directly inside VS Code using a simple .http file.

  • Switch to requests.http in VS Code and add the following test requests:
### Get all books
GET http://localhost:3000/api/books

### Get a single book by ID
GET http://localhost:3000/api/books/1

### Get a book that does not exist (expect 404)
GET http://localhost:3000/api/books/999

What is the ### separator?

The ### lines separate individual requests in the file. Each ### also acts as a comment where you can describe what the request does.

REST Client displays a clickable Send Request link above each request. Click it to fire the request and see the response in a side panel.

  • Save requests.http.
  • Make sure your server is still running in the terminal. If not, run node app.js again.
  • Click Send Request above the first request (GET http://localhost:3000/api/books). You should see a JSON array of three books in the response panel on the right.
  • Click Send Request above the second request (GET http://localhost:3000/api/books/1). You should see a single book object for 1984.
  • Click Send Request above the last request (GET http://localhost:3000/api/books/999). You should see a 404 status and the message Book not found.

REST Client not showing Send Request?

  • Make sure the file is saved with the .http extension. REST Client only activates for .http or .rest files.
  • Check that the REST Client extension is installed by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) and searching for REST Client.
  • If you see a connection error, confirm the server is running. Look for Bookshelf API running at http://localhost:3000 in the terminal.
  • help me get REST Client working in VS Code

✔️ Awesome, I've got everything!

Great. Double check you've saved requests.http.

ⓧ I'd like to double check the full code

Here is the full requests.http file:

### Get all books
GET http://localhost:3000/api/books

### Get a single book by ID
GET http://localhost:3000/api/books/1

### Get a book that does not exist (expect 404)
GET http://localhost:3000/api/books/999

Your bookshelf has books and your API can serve them up on demand. Next, you'll add the ability to create, update, and delete books to complete the full set of CRUD operations.

Build Create, Update, and Delete Routes

Your API can read books, but a real bookshelf needs more than that. You need to add books, update their details, and remove them when you are done.

These three operations map to three HTTP methods: POST for creating, PUT for updating, and DELETE for removing. Together with the two GET routes from the last step, they form a complete set of CRUD operations (Create, Read, Update, Delete).

In this step, get ready to:
  • Add a POST route to create new books.
  • Add PUT and DELETE routes to update and remove books.
  • Test all new endpoints in the .http file.
Add the POST route to create new books

When someone wants to add a book to the collection, they send a POST request with the book's details in the request body. Your route reads that data, assigns a unique ID, and stores the new book.

  • In app.js, find the app.listen() block at the bottom of the file.
  • Add the following POST route directly above app.listen():
// POST - create a new book
app.post('/api/books', (req, res) => {
  // Extract book details from the request body
  const { title, author, year } = req.body;

  // Build the new book with an auto-incrementing ID
  const newBook = {
    id: nextId++,
    title,
    author,
    year: year || null
  };

  // Add to collection and respond with 201 Created
  books.push(newBook);
  res.status(201).json(newBook);
});

What does this code do?

const { title, author, year } = req.body pulls the book details out of the incoming JSON request body. This works because of the express.json() middleware you added in the last step.

id: nextId++ assigns the current value of nextId as the book's ID, then increments it for the next book. res.status(201).json(newBook) sends the created book back with a 201 status code, which means "Created".

  • Save app.js.
  • You won't see a change yet. The next two routes complete the CRUD set, and then you will test everything together.
Add the PUT and DELETE routes

PUT replaces an existing book's data with new values. DELETE removes a book from the collection entirely.

  • In app.js, add the following PUT route directly below the POST route you just added (still above app.listen()):
// PUT - update a book
app.put('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  // Find the position of the book in the array
  const bookIndex = books.findIndex(b => b.id === id);

  // Return 404 if the book doesn't exist
  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const { title, author, year } = req.body;

  // Replace the book data, keeping the original ID
  books[bookIndex] = { id, title, author, year: year || null };

  res.json(books[bookIndex]);
});

What does this code do?

books.findIndex() returns the position of the matching book in the array, or -1 if no book has that ID. This lets you check whether the book exists before trying to update it.

books[bookIndex] = { id, title, author, year: year || null } replaces the old book object with the new data. The original id is kept so the book keeps its identity even when its details change.

  • Add the following DELETE route directly below the PUT route:
// DELETE - remove a book
app.delete('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  // Return 404 if the book doesn't exist
  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  // Remove the book and capture it for the response
  const deletedBook = books.splice(bookIndex, 1)[0];
  res.json({ message: 'Book deleted', book: deletedBook });
});

What does this code do?

books.splice(bookIndex, 1) removes exactly one element from the array at the given position and returns it in a new array. The [0] at the end grabs the removed book object from that array.

The response includes both a confirmation message and the deleted book's data. This is a common pattern so the client can confirm which book was removed.

  • Save app.js.

Seeing a syntax error when you save?

Make sure each route ends with }); and that you placed the new routes above app.listen(), not inside it. A missing closing bracket is the most common cause of syntax errors at this stage.

Help me find my syntax error in app.js

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.js file so far. Compare it with your code to spot any differences.

const express = require('express');
const app = express();
const port = 3000;

app.use(express.json());

let books = [
  { id: 1, title: '1984', author: 'George Orwell', year: 1949 },
  { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: 3, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }
];

let nextId = 4;

app.get('/api/books', (req, res) => {
  res.json(books);
});

app.get('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const book = books.find(b => b.id === id);

  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }

  res.json(book);
});

app.post('/api/books', (req, res) => {
  const { title, author, year } = req.body;

  const newBook = {
    id: nextId++,
    title,
    author,
    year: year || null
  };

  books.push(newBook);
  res.status(201).json(newBook);
});

app.put('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const { title, author, year } = req.body;

  books[bookIndex] = { id, title, author, year: year || null };

  res.json(books[bookIndex]);
});

app.delete('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const deletedBook = books.splice(bookIndex, 1)[0];
  res.json({ message: 'Book deleted', book: deletedBook });
});

app.listen(port, () => {
  console.log(`Bookshelf API running at http://localhost:${port}`);
});
Test all new endpoints in the .http file

Your routes are written, but you need to see them in action. You will restart the server, add test requests to requests.http, and fire each one to verify the correct status codes come back.

  • Switch back to your terminal in VS Code.
  • If the server is still running from the last step, press Ctrl+C to stop it.
  • Restart the server by running:
node app.js
  • You should see Bookshelf API running at http://localhost:3000 in the terminal.

Seeing an error instead of the startup message?

A syntax error here usually means a missing bracket or parenthesis in one of the new routes. Check the error message for a line number and compare that area of your code against the double-check tab above.

Help me fix the error I see when starting my Express server

  • Open requests.http in VS Code.
  • Add the following test requests below the existing GET requests:
### Create a new book
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Dune",
  "author": "Frank Herbert",
  "year": 1965
}

### Update an existing book
PUT http://localhost:3000/api/books/1
Content-Type: application/json

{
  "title": "Nineteen Eighty-Four",
  "author": "George Orwell",
  "year": 1949
}

### Update a book that does not exist (expect 404)
PUT http://localhost:3000/api/books/999
Content-Type: application/json

{
  "title": "Ghost Book",
  "author": "Nobody",
  "year": 2000
}

### Delete a book
DELETE http://localhost:3000/api/books/3

### Delete a book that does not exist (expect 404)
DELETE http://localhost:3000/api/books/999

What does each request test?

  • The POST request creates a brand new book ("Dune") and should return a 201 status.
  • The first PUT request updates book 1's title from "1984" to "Nineteen Eighty-Four" and should return 200.
  • The second PUT targets a book that does not exist (ID 999) and should return 404.
  • The first DELETE removes book 3 and should return 200 with the deleted book's data.
  • The second DELETE targets a missing book and should return 404.
  • Save requests.http.
  • Click the Send Request link that appears above the POST request.
  • A response panel opens on the right side of VS Code showing the created book with a 201 Created status.
  • Click Send Request above the first PUT request. You should see a 200 OK response with the updated title.
  • Click Send Request above the PUT request for book 999. You should see a 404 Not Found response.
  • Click Send Request above the DELETE request for book 3. You should see a 200 OK response with the deleted book's data.
  • Click Send Request above the DELETE request for book 999. You should see a 404 Not Found response.

Not seeing the Send Request link?

Make sure your file is saved with the .http extension. The REST Client extension only shows the Send Request link when the file's language mode is set to HTTP. You can check the language mode in the bottom-right corner of VS Code and click it to switch to HTTP if needed.

If the link still does not appear, try pressing Ctrl+Alt+R (macOS: Cmd+Alt+R) to send the request using the keyboard shortcut instead.

Help me troubleshoot the REST Client extension in VS Code

✔️ Awesome, I've got everything!

Great. Make sure you have saved requests.http before moving on.

ⓧ I'd like to double check the full code

Here is the complete requests.http file so far. Compare it with your file to spot any differences.

### Get all books
GET http://localhost:3000/api/books

### Get a single book by ID
GET http://localhost:3000/api/books/1

### Get a book that does not exist (expect 404)
GET http://localhost:3000/api/books/999

### Create a new book
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Dune",
  "author": "Frank Herbert",
  "year": 1965
}

### Update an existing book
PUT http://localhost:3000/api/books/1
Content-Type: application/json

{
  "title": "Nineteen Eighty-Four",
  "author": "George Orwell",
  "year": 1949
}

### Update a book that does not exist (expect 404)
PUT http://localhost:3000/api/books/999
Content-Type: application/json

{
  "title": "Ghost Book",
  "author": "Nobody",
  "year": 2000
}

### Delete a book
DELETE http://localhost:3000/api/books/3

### Delete a book that does not exist (expect 404)
DELETE http://localhost:3000/api/books/999

Your API now handles the full set of CRUD operations. Next up, you will add custom middleware to log every incoming request and input validation to catch bad data before it reaches your routes.

Add Custom Middleware and Input Validation

Your API now handles all four CRUD operations. You can create, read, update, and delete books with the right status codes and responses.

But what happens when someone sends a request with missing fields or garbage data? Right now, your API happily accepts anything without complaint. In this step, you'll add a logging middleware that tracks every incoming request and input validation that rejects bad data before it reaches your book collection.

In this step, get ready to:
  • Create a custom logging middleware that timestamps every incoming request.
  • Add input validation to your POST and PUT routes.
  • Test validation errors using the REST Client extension.
Create a custom request logging middleware

Middleware functions in Express sit between the incoming request and your route handlers. Every request passes through middleware first, making them perfect for tasks that apply to every route.

A logging middleware gives you visibility into what's hitting your server. Instead of guessing which requests came in, you'll see a timestamped record of each one in your terminal.

  • In app.js, find the line app.use(express.json());.
  • Add the following logging middleware below it, before the let books array:
// Custom logging middleware
app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
});

What does this code do?

  • app.use() registers a middleware function that runs on every incoming request, regardless of the route.
  • new Date().toISOString() creates a timestamp in ISO format.
  • req.method and req.url capture the HTTP method (GET, POST, etc.) and the URL path.
  • next() passes control to the next middleware or route handler. Without it, the request hangs forever.

Why does placement matter?

Express runs middleware in the order you register it. Placing the logger before your routes means every request gets logged. If you placed it after your routes, it would never run because the routes already send a response.

  • Save app.js.
  • In the terminal, stop the server if it's running by pressing Ctrl+C.
  • Start the server again by running:
node app.js
  • Switch to requests.http and click Send Request above the first GET request.
  • Check the terminal. You should see a log line like [2026-06-25T10:30:00.000Z] GET /api/books (your timestamp will differ).

Don't see log output in the terminal?

  • Make sure you added the middleware above the route definitions. Middleware runs in the order it's registered, so it must come before your routes.
  • Check that you called next() inside the middleware. Without it, requests never reach your routes and you won't get a response either.
  • Make sure you stopped the server and restarted it with node app.js. Code changes don't take effect until you restart.
  • Why isn't my logging middleware printing anything?
Add input validation to the POST route

Right now, your POST and PUT routes accept any data, even empty objects. A production API needs to validate incoming data and return helpful error messages when something is wrong.

  • In app.js, find the app.post('/api/books', ...) route handler.
  • Replace the entire POST handler with this updated version that includes validation:
// POST - create a new book
app.post('/api/books', (req, res) => {
  const { title, author, year } = req.body;

  // Check that title is provided and is a non-empty string
  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  // Check that author is provided and is a non-empty string
  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  // Check that year is a number (only if provided)
  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  const newBook = {
    id: nextId++,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  books.push(newBook);
  res.status(201).json(newBook);
});

What does this validation do?

  • Each if block checks one field. If validation fails, the route immediately returns a 400 status code (Bad Request) with a descriptive error message.
  • typeof title !== 'string' ensures the value is actually a string. title.trim() === '' catches strings that are only whitespace.
  • The year check is different. It only validates if year is provided (year !== undefined). Omitting it entirely is fine.
  • The return before res.status(400) stops the handler immediately. Without it, the code would continue and create the book anyway.
  • title.trim() and author.trim() in the newBook object clean up any extra whitespace before storing the data.
  • Now find the app.put('/api/books/:id', ...) route handler.
  • Replace the entire PUT handler with this updated version that adds the same validation:
// PUT - update a book
app.put('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const { title, author, year } = req.body;

  // Validate title
  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  // Validate author
  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  // Validate year (optional, but must be a number if provided)
  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  books[bookIndex] = {
    id,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  res.json(books[bookIndex]);
});

Why the same validation in both routes?

POST creates a new book. PUT replaces an existing book's data. Both accept user input, so both need the same validation rules.

The PUT handler keeps its 404 check from before. Validation only runs after confirming the book exists.

  • Save app.js.
  • In the terminal, stop the server by pressing Ctrl+C and restart it by running:
node app.js

Now it's time to test that invalid requests actually get rejected.

  • In requests.http, find the ### Create a new book request.
  • Add these two validation error test requests below it:
### Create a book without required fields (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Incomplete Book"
}

### Create a book with invalid year (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Bad Year",
  "author": "Test Author",
  "year": "not-a-number"
}

What are these tests for?

  • The first request sends a book with a title but no author. Your validation should catch the missing field and return a 400 error.
  • The second request sends year as a string ("not-a-number") instead of a number. Your validation should reject this too.
  • Save requests.http.
  • Click Send Request above the "Create a book without required fields" request. You should see a 400 response with {"error": "Author is required and must be a non-empty string"}.
  • Click Send Request above the "Create a book with invalid year" request. You should see a 400 response with {"error": "Year must be a number"}.
  • Check the terminal. You should see log lines for both requests, confirming the logging middleware catches everything.

Still getting 200 or 201 instead of 400?

  • Make sure you saved app.js AND restarted the server with node app.js. Code changes don't take effect until you restart.
  • Check that the validation if blocks come before the const newBook line in the POST handler. Validation must run before the book is created.
  • Make sure each validation check has return before res.status(400). Without return, the handler keeps running and creates the book anyway.
  • Why aren't my validation checks returning 400 errors?

✔️ Awesome, I've got everything!

Great. Double check you've saved both app.js and requests.http.

ⓧ I'd like to double check the full code

Here is the full app.js file at this point:

const express = require('express');
const app = express();
const port = 3000;

app.use(express.json());

app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
});

let books = [
  { id: 1, title: '1984', author: 'George Orwell', year: 1949 },
  { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: 3, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }
];

let nextId = 4;

app.get('/api/books', (req, res) => {
  res.json(books);
});

app.get('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const book = books.find(b => b.id === id);

  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }

  res.json(book);
});

app.post('/api/books', (req, res) => {
  const { title, author, year } = req.body;

  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  const newBook = {
    id: nextId++,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  books.push(newBook);
  res.status(201).json(newBook);
});

app.put('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const { title, author, year } = req.body;

  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  books[bookIndex] = {
    id,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  res.json(books[bookIndex]);
});

app.delete('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const deletedBook = books.splice(bookIndex, 1)[0];
  res.json({ message: 'Book deleted', book: deletedBook });
});

app.listen(port, () => {
  console.log(`Bookshelf API running at http://localhost:${port}`);
});

And here is the full requests.http file:

### Get all books
GET http://localhost:3000/api/books

### Get a single book by ID
GET http://localhost:3000/api/books/1

### Get a book that does not exist (expect 404)
GET http://localhost:3000/api/books/999

### Create a new book
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Dune",
  "author": "Frank Herbert",
  "year": 1965
}

### Create a book without required fields (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Incomplete Book"
}

### Create a book with invalid year (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Bad Year",
  "author": "Test Author",
  "year": "not-a-number"
}

### Update an existing book
PUT http://localhost:3000/api/books/1
Content-Type: application/json

{
  "title": "Nineteen Eighty-Four",
  "author": "George Orwell",
  "year": 1949
}

### Update a book that does not exist (expect 404)
PUT http://localhost:3000/api/books/999
Content-Type: application/json

{
  "title": "Ghost Book",
  "author": "Nobody",
  "year": 2000
}

### Delete a book
DELETE http://localhost:3000/api/books/3

### Delete a book that does not exist (expect 404)
DELETE http://localhost:3000/api/books/999

Your API now logs every request and rejects bad data with clear error messages. Next up, you'll add a centralized error handler for unexpected failures and run a complete test suite across every endpoint.

Add Error Handling and Test the Complete API

Your API now handles predictable problems like missing books and invalid input. But what happens when something truly unexpected goes wrong inside your server code?

Without proper error handling, an unhandled error crashes your entire server. In this step, you'll add a safety net that catches those errors and returns a clean JSON response instead of leaving your API silent.

In this step, get ready to:
  • Add a centralized error handling middleware to catch unexpected errors.
  • Run the complete test suite in your .http file to verify every endpoint.
Add a centralized error handling middleware

Express recognizes error handling middleware by its function signature. A middleware function with exactly four arguments is treated as an error handler.

  • In app.js, find the app.listen() call at the bottom of your file.
  • Add the following code directly above app.listen():
// Test route for error handling
app.get('/api/test-error', (req, res) => {
  throw new Error('This is a test error!');
});

// Centralized error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

What does this code do?

  • app.get('/api/test-error', ...) is a route that deliberately throws an error. It exists purely so you can verify the error handler works.
  • app.use((err, req, res, next) => { ... }) is the error handling middleware. Express knows it handles errors because it has exactly four arguments.
  • console.error(err.stack) logs the full error stack trace to the terminal so you can debug it.
  • res.status(500).json({ error: 'Something went wrong!' }) sends a clean 500 response to the client instead of crashing the server.

Why does this middleware go after all routes?

Express processes middleware in the order you define it. The error handler must come after all your routes so it can catch errors thrown by any of them. If you placed it before your routes, those routes wouldn't have a safety net.

  • Save app.js.
  • If your server is still running in the terminal, press Ctrl+C to stop it.
  • Start the server again by running:
node app.js
  • Open your browser and visit http://localhost:3000/api/test-error.
  • You should see {"error":"Something went wrong!"} in the browser. In your terminal, you should also see the error stack trace logged.

Not seeing the JSON error response?

  • Make sure the error handling middleware has exactly four arguments: (err, req, res, next). If you leave out any of them, Express treats it as regular middleware instead of an error handler.
  • Check that the error handler is placed after all your routes but before app.listen().
  • Make sure you stopped and restarted the server after saving your changes.

Why isn't my Express error handler catching the thrown error?

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const app = express();
const port = 3000;

app.use(express.json());

app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
});

let books = [
  { id: 1, title: '1984', author: 'George Orwell', year: 1949 },
  { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: 3, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }
];

let nextId = 4;

app.get('/api/books', (req, res) => {
  res.json(books);
});

app.get('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const book = books.find(b => b.id === id);

  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }

  res.json(book);
});

app.post('/api/books', (req, res) => {
  const { title, author, year } = req.body;

  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  const newBook = {
    id: nextId++,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  books.push(newBook);
  res.status(201).json(newBook);
});

app.put('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const { title, author, year } = req.body;

  if (!title || typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'Title is required and must be a non-empty string' });
  }

  if (!author || typeof author !== 'string' || author.trim() === '') {
    return res.status(400).json({ error: 'Author is required and must be a non-empty string' });
  }

  if (year !== undefined && typeof year !== 'number') {
    return res.status(400).json({ error: 'Year must be a number' });
  }

  books[bookIndex] = {
    id,
    title: title.trim(),
    author: author.trim(),
    year: year || null
  };

  res.json(books[bookIndex]);
});

app.delete('/api/books/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookIndex = books.findIndex(b => b.id === id);

  if (bookIndex === -1) {
    return res.status(404).json({ error: 'Book not found' });
  }

  const deletedBook = books.splice(bookIndex, 1)[0];
  res.json({ message: 'Book deleted', book: deletedBook });
});

app.get('/api/test-error', (req, res) => {
  throw new Error('This is a test error!');
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

app.listen(port, () => {
  console.log(`Bookshelf API running at http://localhost:${port}`);
});
Run the complete test suite in the .http file

Your API is feature-complete. Time to organize all your test requests and run them end to end to make sure everything works together.

  • Switch to requests.http in VS Code.
  • Find the very first line of the file.
  • Add a section header label at the very top by pasting this line above everything else:
### --- BOOKSHELF API TEST REQUESTS ---
  • Make sure there is a blank line between this header and the next ### comment.
  • Scroll to the very bottom of the file, after the last DELETE request.
  • Add the error handler test request at the bottom by pasting this code:
### Test the error handler (expect 500)
GET http://localhost:3000/api/test-error

What does your test file cover now?

Your requests.http file is now a complete test suite with four categories of tests:

  • Happy paths that confirm all CRUD operations work (GET all, GET by ID, POST, PUT, DELETE).
  • Validation errors that confirm missing or invalid fields return 400.
  • Not-found errors that confirm requests for non-existent books return 404.
  • Server errors that confirm the error handler catches thrown errors and returns 500.
  • Save requests.http.
  • Make sure your server is still running in the terminal. If it stopped, restart it with node app.js.
  • Click Send Request above the first request (GET /api/books). Confirm you get a 200 response with an array of books.
  • Click Send Request above each remaining request, one at a time.

Here is what to expect for each request:

  • GET /api/books returns 200 with the full books array.
  • GET /api/books/1 returns 200 with a single book object.
  • GET /api/books/999 returns 404 with an error message.
  • POST with a valid body returns 201 with the new book.
  • POST with missing fields returns 400 with a validation error.
  • POST with an invalid year returns 400 with a type error.
  • PUT with a valid body returns 200 with the updated book.
  • PUT for a missing book returns 404.
  • DELETE returns 200 with a confirmation message.
  • DELETE for a missing book returns 404.
  • GET /api/test-error returns 500 with {"error":"Something went wrong!"}.
  • Check your terminal after running all the requests. You should see a log line for every request you sent, each with a timestamp, method, and URL.

Getting unexpected status codes?

  • If POST returns 400 when you expect 201, check that your JSON body includes both title and author fields.
  • If you see a connection error, make sure your server is still running in the terminal.
  • Make sure each request in requests.http is separated by a ### line. Without the separator, the REST Client extension may combine multiple requests into one.

Help me debug why my REST Client requests are returning unexpected status codes.

✔️ Awesome, I've got everything!

Great. Double check you've saved requests.http before moving on.

ⓧ I'd like to double check the full code

### --- BOOKSHELF API TEST REQUESTS ---

### Get all books
GET http://localhost:3000/api/books

### Get a single book by ID
GET http://localhost:3000/api/books/1

### Get a book that does not exist (expect 404)
GET http://localhost:3000/api/books/999

### Create a new book
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Dune",
  "author": "Frank Herbert",
  "year": 1965
}

### Create a book without required fields (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Incomplete Book"
}

### Create a book with invalid year (expect 400)
POST http://localhost:3000/api/books
Content-Type: application/json

{
  "title": "Bad Year",
  "author": "Test Author",
  "year": "not-a-number"
}

### Update an existing book
PUT http://localhost:3000/api/books/1
Content-Type: application/json

{
  "title": "Nineteen Eighty-Four",
  "author": "George Orwell",
  "year": 1949
}

### Update a book that does not exist (expect 404)
PUT http://localhost:3000/api/books/999
Content-Type: application/json

{
  "title": "Ghost Book",
  "author": "Nobody",
  "year": 2000
}

### Delete a book
DELETE http://localhost:3000/api/books/3

### Delete a book that does not exist (expect 404)
DELETE http://localhost:3000/api/books/999

### Test the error handler (expect 500)
GET http://localhost:3000/api/test-error

Secret mission

Add Search and Filtering with Query Parameters

Your bookshelf API can create, read, update, and delete books. But what happens when your collection grows to hundreds of entries and you need to find a specific title? In this secret mission, you'll add search and filtering capabilities using query parameters so users can find exactly the book they're looking for.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • bookshelf-api project folder (includes app.js, requests.http, package.json, and node_modules).
  • REST Client VS Code extension.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your API.

  • Your server continues running on port 3000 as long as the VS Code terminal stays open.
  • Your project files remain in the bookshelf-api folder on your Desktop.
  • The REST Client extension stays available in VS Code for future projects.

Pause - I'll come back to this later

Shut down the running server to free up your terminal, but keep your project files for later.

  • Stop the server by pressing Ctrl+C in the VS Code terminal.
  • Your project files stay in the bookshelf-api folder on your Desktop.

To restart later, open the bookshelf-api folder in VS Code and run:

node app.js

Delete - I don't want to use this again

Remove all project files and start fresh.

  • Stop the server by pressing Ctrl+C in the VS Code terminal.
  • In VS Code, click File in the top menu bar.
  • Click Close Folder.
  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/bookshelf-api

Windows

Remove-Item -Recurse -Force ~/Desktop/bookshelf-api
  • To uninstall the REST Client extension, open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Find REST Client in your installed extensions.
  • Click Uninstall.

Nice Work!

Nice Work!

You just built a fully functional REST API from scratch using Node.js and Express 5. Your bookshelf API handles real CRUD operations, validates input, logs every request, and catches errors gracefully.

You've learned how to:

  • Build a REST API with all four CRUD operations (Create, Read, Update, Delete) using proper HTTP methods and status codes.
  • Create custom middleware for request logging, input validation with meaningful error messages, and centralized error handling for unexpected failures.
  • Test every endpoint directly inside VS Code using the REST Client extension and .http files that cover happy paths, validation errors, and edge cases.
  • Secret Mission: Added search and filtering with query parameters for flexible book lookups.

Ready to quiz yourself?