Build a Bookstore REST API
Build a CRUD REST API for a bookstore catalog using Express.js.
Introduction
30 Second Summary
Every time you browse Netflix, add something to your Amazon cart, or scroll through Spotify playlists, your app is talking to a backend server through an API. That server decides what data to send back, what to save, and what to delete.
In this project, you will build a REST API from scratch using Express.js that manages a bookstore catalog. You will handle requests to create, read, update, and delete books, just like a real backend engineer.
What You'll Build
A fully functional Bookstore REST API running locally that responds to HTTP requests, returns JSON data, and uses proper HTTP status codes.
By the end of this project, you'll have:
- A running Express.js server that handles GET, POST, PUT, and DELETE requests for a bookstore catalog.
- Hands-on experience with middleware, routing, and JSON responses using proper HTTP status codes.
- A complete CRUD API you can demo in interviews that proves you understand how backends work.
- Secret Mission: Add search and filtering so users can find books by author or title using query parameters.
Are there any prerequisites?
None. This project includes setup instructions for all required tools. You do not need prior experience with JavaScript or backend development.
Set Up Your Project
Every API needs a home before you can start writing routes and handling requests. In this step, you'll create a project folder, tell Node.js how to manage your dependencies, and install the framework that makes building APIs fast.
By the end of this step, you'll have a running server that proves your setup works. That running server becomes the foundation for every route you build in the rest of this project.
In this step, get ready to:
- Create and initialize a project folder with npm.
- Install Express as a dependency.
- Run a test server to confirm your setup works.
Create your project folder and initialize npm
Before writing any code, you need a dedicated folder for your project and a package.json file that tracks your project's metadata and dependencies.
- In VS Code, open a new terminal by pressing Ctrl+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and generate a package.json file by running these commands:
mkdir bookstore-api
cd bookstore-api
npm init -y
What do these commands do?
- mkdir bookstore-api creates a new folder called bookstore-api on your Desktop.
- cd bookstore-api moves your terminal into that folder.
- npm init -y generates a package.json file with default values. The -y flag skips all the setup prompts.
- Confirm your files were created by running:
ls
You should see package.json listed in the output.
Don't see package.json?
- Make sure you ran cd bookstore-api before running npm init -y. The init command creates package.json in whatever folder your terminal is currently in.
- If you see an error about npm not being found, Node.js may not be installed correctly.
Help me troubleshoot why npm init isn't creating my package.json file.
Now you need to open this project folder in VS Code so you can edit files in the Explorer sidebar.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the bookstore-api folder, then click Open.
What happens when VS Code opens the folder?
VS Code reloads with bookstore-api as your workspace root. You'll see package.json in the Explorer sidebar on the left. Any new terminal you open will automatically start inside this folder.
Next, you need to enable modern JavaScript import syntax by adding one line to package.json.
- In the Explorer sidebar, click package.json to open it.
- Find this line:
"main": "app.js",
- Add a new line directly below it with "type": "module", so the two lines now look like this:
"main": "app.js",
"type": "module",
Why add type module?
Setting "type": "module" tells Node.js to use modern import syntax instead of the older require() style. This matches the syntax used in the Express documentation and most modern JavaScript projects.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Install Express
Express is a lightweight framework that handles incoming HTTP requests and sends back responses. It gives you tools to define routes, parse request data, and send JSON back to clients.
- Open a new terminal in VS Code by pressing Ctrl+Shift+`.
- Install Express by running this command:
npm install express
- Confirm the installation succeeded by running:
ls
You should see node_modules and package-lock.json alongside your package.json.
What are these new files?
- node_modules is a folder containing all the code Express (and its own dependencies) need to run.
- package-lock.json locks the exact versions of every dependency so your project behaves the same on any machine.
Seeing permission errors or EACCES?
- Make sure your terminal is inside the bookstore-api folder. Run pwd to check your current location.
- If you see a permission error on macOS, try prefixing the command with sudo. On Windows, try running VS Code as Administrator.
Help me fix a permission error when running npm install express.
Verify your setup with a test server
The best way to confirm everything works is to write a tiny server and run it. If you can see a response in your browser, your setup is solid.
- Create a new file called app.js by right-clicking in the Explorer sidebar and selecting New File.
- Name the file app.js and press Enter.
- Copy the following code into app.js:
import express from 'express';
// Create the Express app and define the port
const app = express();
const port = 3000;
// Define a test route at the root URL
app.get('/', (req, res) => {
res.send('Bookstore API is running!');
});
// Start the server and log the URL
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
What does this code do?
- import express from 'express' loads the Express framework you just installed.
- const app = express() creates your application instance. Every route and piece of middleware attaches to this object.
- app.get('/', ...) defines a route that responds when someone visits the root URL with a GET request.
- app.listen(port, ...) starts the server and tells it to wait for incoming requests on port 3000.
- Save app.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Start your server by running this command in the terminal:
node app.js
You should see this message in your terminal:
Server listening on http://localhost:3000
- Open your browser and navigate to http://localhost:3000.
You should see the text "Bookstore API is running!" displayed in your browser.
- Return to your terminal in VS Code and press Ctrl+C to stop the server.
Don't see the message in your browser?
- Check your terminal for error messages. If you see SyntaxError: Cannot use import statement outside a module, you're missing "type": "module" in your package.json. Go back and add it.
- If you see EADDRINUSE, another process is already using port 3000. Press Ctrl+C and try again.
- Make sure you saved both package.json and app.js before running node app.js.
Help me debug why my Express server isn't responding at localhost:3000.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files, then move on to the next step.
ⓧ I'd like to double check the full code
Here's what app.js should look like:
import express from 'express';
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Bookstore API is running!');
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
And here's what package.json should look like:
{
"name": "bookstore-api",
"version": "1.0.0",
"description": "",
"main": "app.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1"
}
}
Your environment is ready and your server is confirmed working. Next up, you'll add book data and build your first real API routes that return JSON responses.
Add Book Data and GET Routes
Your Express server is running, but it only returns a single text message. A real API needs data to serve and routes that know how to find that data.
In this step, you'll add sample book data to your server and create two GET routes that let clients request book information. This is the foundation of your bookstore catalog.
In this step, get ready to:
- Add an in-memory data store with sample books and enable JSON middleware.
- Create a GET /books route that returns all books.
- Create a GET /books/:id route that returns a single book by ID.
Add sample book data and JSON middleware
Before your API can serve book data, it needs two things: data to serve and the ability to understand incoming JSON requests. You'll set up both now by replacing the contents of app.js with a new foundation.
- Open app.js in VS Code and select all the existing code.
- Replace everything with the following code:
import express from 'express';
const app = express();
const port = 3000;
// Middleware: parse JSON request bodies
app.use(express.json());
// In-memory "database" of books
let books = [
{ id: 1, title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 },
{ id: 2, title: '1984', author: 'George Orwell', year: 1949 },
{ id: 3, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
let nextId = 4;
// GET / - Welcome message
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
What does this code do?
This code sets up the foundation for your bookstore API. Here's what each piece does:
- app.use(express.json()) is middleware that automatically parses incoming JSON request bodies. Without it, your server can't read data that clients send.
- The books array acts as an in-memory database. It holds three sample books, each with an id, title, author, and year.
- nextId tracks what ID to assign to the next book that gets created.
- The GET / route now returns JSON instead of plain text, which is the standard format for API responses.
- Save app.js.
- Start the server by running this command in your terminal:
node app.js
- Open http://localhost:3000 in your browser. You should see {"message":"Welcome to the Bookstore API"} instead of the plain text from before.
Seeing an error when you start the server?
- If you see a syntax error, double check that you replaced all the old code. There should be no leftover res.send() line from the previous version.
- If you see EADDRINUSE, the port is still in use from an earlier run. Press Ctrl+C to stop any running servers, then try again.
- Help me debug my server startup error.
Create GET /books to return all books
Your API has book data, but no way to access it yet. You'll add a route that responds when a client sends a GET request to /books.
- In app.js, find the closing }); of the GET / route (the one with Welcome to the Bookstore API).
- Add the following route below it, before app.listen:
// GET /books - Return all books
app.get('/books', (req, res) => {
res.json(books);
});
What does this code do?
This route listens for GET requests to /books. When a request arrives, it responds with the entire books array as JSON. Express automatically sets the Content-Type header to application/json and sends a 200 status code when you use res.json().
- Save app.js.
- Switch to your terminal and press Ctrl+C to stop the running server.
- Restart the server by running:
node app.js
- Open http://localhost:3000/books in your browser. You should see a JSON array containing all three books with their IDs, titles, authors, and years.
Not seeing any books?
- Make sure you restarted the server after saving. Node.js does not pick up file changes automatically.
- Check that your new route is placed before app.listen(). Routes defined after the server starts listening won't work.
- Help me debug my GET /books route.
Create GET /books/:id to return a single book
Clients often need to fetch one specific book, not the entire collection. URL parameters let you capture a value from the URL path and use it to look up data.
- In app.js, find the closing }); of the GET /books route you just added.
- Add the following route below it, before app.listen:
// GET /books/:id - Return a single book
app.get('/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 does this code do?
The :id in the route path is a URL parameter. When someone visits /books/2, Express captures 2 and stores it in req.params.id.
- parseInt() converts the URL parameter from a string to a number so it matches the numeric IDs in your books array.
- books.find() searches the array for a book with a matching ID.
- If no book matches, the server responds with a 404 status code and an error message. This tells the client the requested resource doesn't exist.
- Save app.js.
- Press Ctrl+C in your terminal, then restart the server:
node app.js
- Open http://localhost:3000/books/1 in your browser. You should see a single JSON object for The Hobbit.
- Now try http://localhost:3000/books/99. You should see {"error":"Book not found"} with a 404 status.
Getting an empty response or an error?
- Make sure the route path is '/books/:id' with a colon before id. The colon tells Express this is a parameter, not a literal path segment.
- Check that parseInt(req.params.id) uses req.params (not req.body or req.query). URL parameters live in req.params.
- Help me debug my GET /books/:id route.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js and your server is running.
ⓧ I'd like to double check the full code
Here's the complete app.js file at this point:
import express from 'express';
const app = express();
const port = 3000;
// Middleware: parse JSON request bodies
app.use(express.json());
// In-memory "database" of books
let books = [
{ id: 1, title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 },
{ id: 2, title: '1984', author: 'George Orwell', year: 1949 },
{ id: 3, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
let nextId = 4;
// GET / - Welcome message
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
// GET /books - Return all books
app.get('/books', (req, res) => {
res.json(books);
});
// GET /books/:id - Return a single book
app.get('/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(`Server listening on http://localhost:${port}`);
});
Your API can now read data. Next up, you'll add the ability to create new books with a POST route.
Add POST Route to Create Books
Your API can read books, but a bookstore that never gets new inventory is not very useful. In this step, you'll give clients the ability to add new books to the collection.
You'll use the POST HTTP method, which is the standard way to send new data to a server. This is the "Create" in CRUD (Create, Read, Update, Delete).
In this step, get ready to:
- Create a POST route that accepts new book data and responds with the created book.
- Add validation to reject incomplete requests.
- Test the route using curl.
Add the POST route to your server
When a client sends a POST request, they include data in the request body. Your route needs to read that data, create a new book object, and send it back as confirmation.
- In app.js, find the closing }); of your GET /books/:id route.
- Add the following code below it, before the app.listen line:
// POST /books - Create a new book
app.post('/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
const newBook = { id: nextId++, title, author, year: year || null };
books.push(newBook);
res.status(201).json(newBook);
});
What does this code do?
This route handles POST requests to /books. Here's what each part does:
- const { title, author, year } = req.body pulls the book data from the incoming JSON request body. This works because express.json() middleware parses the raw JSON into a JavaScript object.
- if (!title || !author) checks that the client sent both required fields. If either is missing, the server responds with a 400 (Bad Request) status code.
- nextId++ assigns a unique ID to the new book and increments the counter for the next one.
- res.status(201).json(newBook) sends back the created book with a 201 (Created) status code, confirming the book was added successfully.
- Save app.js.
- You won't see a change yet. You'll restart the server and test this route in the next substep.
Understand the validation logic
Why validate input?
Without validation, a client could send an empty object or random data, and your API would happily store it. The if (!title || !author) check prevents this by requiring the two essential fields before creating a book.
Returning a 400 status code tells the client "you made a mistake in your request" rather than pretending everything worked. This is how production APIs communicate problems back to their callers.
What is a 201 status code?
HTTP status codes are how servers communicate results. A 200 means "OK, here's what you asked for." A 201 means "OK, and I created something new for you." Using 201 instead of 200 for POST requests is a best practice that helps clients know exactly what happened.
Test your POST route with curl
Now you'll send a real POST request to your API and confirm the new book gets created.
- Switch back to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Restart the server so it picks up your new code by running:
node app.js
- Open a second terminal in VS Code by clicking Terminal in the menu bar, then New Terminal.
Why a second terminal?
Your server needs to keep running in the first terminal so it can receive requests. The second terminal is where you'll type curl commands to talk to the server.
- Send a POST request to create a new book by running this command:
macOS
curl -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
Windows
In PowerShell, JSON quoting works differently. Use this command:
curl.exe -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{\"title\": \"Dune\", \"author\": \"Frank Herbert\", \"year\": 1965}'
You should see this response, confirming the book was created with ID 4:
{"id":4,"title":"Dune","author":"Frank Herbert","year":1965}
- Verify the new book is stored by running:
curl http://localhost:3000/books
You should see all four books returned as a JSON array, including your new "Dune" entry.
- Test the validation by sending a request missing the author field:
macOS
curl -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{"title": "Incomplete Book"}'
Windows
curl.exe -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{\"title\": \"Incomplete Book\"}'
You should see a 400 error response:
{"error":"Title and author are required"}
Not getting the expected response?
- Make sure you restarted the server after saving your changes. Node.js does not auto-reload.
- Check that app.use(express.json()) appears before your routes. Without it, req.body will be undefined.
- On Windows, make sure you're using curl.exe (not just curl). PowerShell aliases curl to a different command.
Still stuck? Help me debug my POST route.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js and your server is still running.
ⓧ I'd like to double check the full code
Here's the complete app.js file at this point:
import express from 'express';
const app = express();
const port = 3000;
// Middleware: parse JSON request bodies
app.use(express.json());
// In-memory "database" of books
let books = [
{ id: 1, title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 },
{ id: 2, title: '1984', author: 'George Orwell', year: 1949 },
{ id: 3, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
let nextId = 4;
// GET / - Welcome message
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
// GET /books - Return all books
app.get('/books', (req, res) => {
res.json(books);
});
// GET /books/:id - Return a single book
app.get('/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);
});
// POST /books - Create a new book
app.post('/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
const newBook = { id: nextId++, title, author, year: year || null };
books.push(newBook);
res.status(201).json(newBook);
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Your API can now create books on demand. Next up, you'll add the ability to update and delete books to complete the full CRUD cycle.
Add PUT and DELETE Routes
Your API can now read books and create new ones. Those are only two of the four CRUD operations (Create, Read, Update, Delete) that every backend needs.
In this step, you will add the remaining two operations. PUT lets clients update an existing book's details, and DELETE lets them remove a book entirely.
In this step, get ready to:
- Create a PUT route that updates a book by ID.
- Create a DELETE route that removes a book by ID.
- Test the full CRUD cycle with curl.
Create PUT /books/:id to update a book
A PUT request tells the server to replace or update a resource. Your route will find the book by its ID, update only the fields the client sends, and return the updated book.
- In app.js, find the closing }); of your POST /books route.
- Add the following PUT route below it (before app.listen):
// PUT /books/:id - Update a book
app.put('/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' });
}
const { title, author, year } = req.body;
if (title) book.title = title;
if (author) book.author = author;
if (year) book.year = year;
res.json(book);
});
What does this code do?
- app.put('/books/:id', ...) registers a handler for PUT requests to /books/1, /books/2, etc.
- parseInt(req.params.id) converts the URL parameter from a string to a number so it can match the id field in your books array.
- The three if statements only overwrite a field if the client actually sent a new value. This lets clients update just one field at a time without erasing the others.
- res.json(book) sends back the updated book with a default 200 status code.
You won't see a change yet. The next substep adds the DELETE route, then you will restart the server and test both.
Create DELETE /books/:id to remove a book
A DELETE request tells the server to remove a resource permanently. When a delete succeeds, the convention is to respond with a 204 (No Content) status and an empty body.
- In app.js, add the following DELETE route directly below the PUT route you just added (still before app.listen):
// DELETE /books/:id - Delete a book
app.delete('/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' });
}
books.splice(bookIndex, 1);
res.status(204).send();
});
What does this code do?
- books.findIndex(b => b.id === id) returns the position of the matching book in the array, or -1 if no match exists.
- books.splice(bookIndex, 1) removes exactly one element at that position from the array.
- res.status(204).send() returns a 204 No Content response. The .send() method is used instead of .json() because 204 responses must have no body.
Why findIndex instead of find?
The PUT route uses .find() because it needs the book object itself to update its properties. The DELETE route uses .findIndex() because .splice() needs the position number, not the object.
- Save app.js.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Restart the server by running:
node app.js
- You should see Server listening on http://localhost:3000 printed again with no errors.
Seeing a syntax error on restart?
- Check that each route ends with }); (closing brace, parenthesis, semicolon). A missing character here is the most common cause.
- Make sure the DELETE route sits after the PUT route and before app.listen.
If you are stuck, help me debug my syntax error in app.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js and confirmed the server starts without errors.
ⓧ I'd like to double check the full code
import express from 'express';
const app = express();
const port = 3000;
// Middleware: parse JSON request bodies
app.use(express.json());
// In-memory "database" of books
let books = [
{ id: 1, title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 },
{ id: 2, title: '1984', author: 'George Orwell', year: 1949 },
{ id: 3, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
let nextId = 4;
// GET / - Welcome message
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
// GET /books - Return all books
app.get('/books', (req, res) => {
res.json(books);
});
// GET /books/:id - Return a single book
app.get('/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);
});
// POST /books - Create a new book
app.post('/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
const newBook = { id: nextId++, title, author, year: year || null };
books.push(newBook);
res.status(201).json(newBook);
});
// PUT /books/:id - Update a book
app.put('/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' });
}
const { title, author, year } = req.body;
if (title) book.title = title;
if (author) book.author = author;
if (year) book.year = year;
res.json(book);
});
// DELETE /books/:id - Delete a book
app.delete('/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' });
}
books.splice(bookIndex, 1);
res.status(204).send();
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Test the full CRUD cycle
Your API now supports all four CRUD operations. Let's prove it works by updating a book, deleting a book, and confirming the changes.
- Switch to your second terminal (the one you use for curl commands).
- Update book 1's title from "The Hobbit" to "The Lord of the Rings" by running:
macOS
curl -X PUT -H "Content-Type: application/json" -d '{"title":"The Lord of the Rings"}' http://localhost:3000/books/1
Windows
curl.exe -X PUT -H "Content-Type: application/json" -d "{\"title\":\"The Lord of the Rings\"}" http://localhost:3000/books/1
- You should see the updated book returned as JSON with the new title and the original author and year intact.
What should I see?
The response shows the full book object with the updated title:
{"id":1,"title":"The Lord of the Rings","author":"J.R.R. Tolkien","year":1937}
- Delete book 2 ("1984") by running:
curl -X DELETE http://localhost:3000/books/2
- You will see no output. That is correct. A 204 No Content response has no body.
- Confirm your changes by fetching all books:
curl http://localhost:3000/books
- You should see only two books. Book 1 now has the title "The Lord of the Rings", and book 2 ("1984") is gone.
What should I see?
[{"id":1,"title":"The Lord of the Rings","author":"J.R.R. Tolkien","year":1937},{"id":3,"title":"To Kill a Mockingbird","author":"Harper Lee","year":1960}]
Your array is now down to two books. The data resets every time you restart the server because it lives in memory, not a database.
Not seeing the expected response?
- Make sure your server is still running in the other terminal. If you see "Connection refused", restart it with node app.js.
- On Windows, use curl.exe instead of curl in PowerShell. The bare curl command in PowerShell is an alias for a different tool.
- If the PUT returns the book unchanged, check that your -H "Content-Type: application/json" header is present. Without it, Express cannot parse the JSON body.
If you are stuck, help me troubleshoot my curl commands.
You now have a complete CRUD API. Next up, you will add error-handling middleware so your server responds gracefully when something unexpected happens.
Add Error Handling Middleware
Your API handles all four CRUD operations, but what happens when someone sends a request to a route that doesn't exist?
Right now, Express sends its own default HTML error page for undefined routes. That's fine for a website, but an API should always respond with consistent JSON. In this step, you'll add middleware that catches bad requests and unexpected errors gracefully.
In this step, get ready to:
- Add a catch-all middleware that returns a 404 JSON response for any undefined route.
- Add an error-handling middleware that catches unexpected server errors and returns a 500 JSON response.
- Test your error handling by hitting a nonexistent endpoint.
Add a catch-all route for undefined endpoints
Express processes middleware and routes in the order you define them. If a request doesn't match any of your routes, it falls through the entire stack. By placing a catch-all at the bottom (after all your defined routes), you intercept anything that fell through.
- In app.js, find the app.listen(port, ...) block at the bottom of your file.
- Add this code directly above app.listen:
// Catch-all for undefined routes
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
What does this code do?
- app.use(...) without a path matches every request that reaches it. Since it sits after all your defined routes, only unmatched requests will get here.
- res.status(404).json(...) sets the HTTP status to 404 (Not Found) and sends a JSON error message instead of Express's default HTML page.
- Save app.js.
- You won't see a change yet. The next chunk adds the error handler, then you'll restart and test both.
Add an error-handling middleware
Express identifies error-handling middleware by its four-argument signature. If any route calls next(err) or throws an unexpected error, Express skips all normal middleware and jumps straight to the error handler.
- Directly below the catch-all you just added (still above app.listen), add this code:
// 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?
- The (err, req, res, next) signature with four arguments is how Express recognizes this as an error handler. You must include all four parameters even if you don't use next.
- console.error(err.stack) logs the full error trace to your terminal so you can debug the problem.
- res.status(500).json(...) sends a generic message to the client without leaking internal details about what went wrong.
- Save app.js.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Restart the server by running:
node app.js
- You should see Server listening on http://localhost:3000 in the terminal.
Server won't start?
- Check for a SyntaxError in the terminal output. Make sure both middleware blocks have matching opening and closing parentheses and braces.
- Confirm the catch-all and error handler are placed after all your route definitions but before app.listen.
- Make sure the error-handling middleware has exactly four parameters: (err, req, res, next).
Help me debug why my Express server won't start after adding error-handling middleware.
Test the full error handling
- Switch to your second terminal window (the one you use for curl commands).
- Test an undefined route by running:
curl http://localhost:3000/pizza
- You should see a clean JSON response: {"error":"Route not found"}.
Why JSON instead of HTML?
Without your catch-all middleware, Express would respond with its own HTML error page. That's confusing for API clients that expect JSON. Your middleware ensures every response from your API is machine-readable JSON, no matter what endpoint is requested.
Now update your package.json with a proper description and start script so your project is complete.
- In package.json, find the "description" line:
"description": "",
- Replace it with:
"description": "A REST API for managing a bookstore catalog",
- Find the "scripts" section:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
- Replace it with:
"scripts": {
"start": "node app.js"
},
- Save package.json.
What does the start script do?
Adding a start script lets anyone run your server with npm start instead of remembering the exact filename. This is the standard convention for Node.js projects.
Still seeing HTML when you hit /pizza?
- Make sure you restarted the server after saving app.js. Changes only take effect after a restart.
- Confirm the catch-all app.use((req, res) => {...}) is placed after all your route definitions (app.get, app.post, app.put, app.delete).
Why am I getting HTML instead of JSON when I hit an undefined route in Express?
✔️ Awesome, I've got everything!
Great. Double check you've saved both app.js and package.json.
ⓧ I'd like to double check the full code
Here is the complete app.js at this point:
import express from 'express';
const app = express();
const port = 3000;
// Middleware: parse JSON request bodies
app.use(express.json());
// In-memory "database" of books
let books = [
{ id: 1, title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 },
{ id: 2, title: '1984', author: 'George Orwell', year: 1949 },
{ id: 3, title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
let nextId = 4;
// GET / - Welcome message
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
// GET /books - Return all books
app.get('/books', (req, res) => {
res.json(books);
});
// GET /books/:id - Return a single book
app.get('/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);
});
// POST /books - Create a new book
app.post('/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
const newBook = { id: nextId++, title, author, year: year || null };
books.push(newBook);
res.status(201).json(newBook);
});
// PUT /books/:id - Update a book
app.put('/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' });
}
const { title, author, year } = req.body;
if (title) book.title = title;
if (author) book.author = author;
if (year) book.year = year;
res.json(book);
});
// DELETE /books/:id - Delete a book
app.delete('/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' });
}
books.splice(bookIndex, 1);
res.status(204).send();
});
// Catch-all for undefined routes
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Error-handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong' });
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
And here is the complete package.json:
{
"name": "bookstore-api",
"version": "1.0.0",
"description": "A REST API for managing a bookstore catalog",
"main": "app.js",
"type": "module",
"scripts": {
"start": "node app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1"
}
}
Your API is now bulletproof. It handles valid requests, invalid data, missing resources, undefined routes, and unexpected errors. That's a production-grade pattern you'll use in every backend project.
Secret mission
Add Search and Filtering
Your API returns all books every time. What if a user only wants books by a specific author? Add query parameter support so users can search and filter the bookstore catalog.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- A running Node.js server process on port 3000.
- The bookstore-api project folder on your Desktop (includes app.js, package.json, and node_modules/).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend your API right away.
- Your server keeps running at http://localhost:3000 until you close the terminal or press Ctrl+C.
- Your project folder stays on your Desktop, ready to open in VS Code whenever you want.
Pause - I'll come back to this later
Stop the running server to free up port 3000, but keep your project files for later.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Your bookstore-api folder remains on your Desktop. To resume later, open the folder in VS Code and run node app.js in the terminal.
Delete - I don't want to use this again
Remove all project files and stop the server. This completely cleans up everything created during this project.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/bookstore-api
On Windows, run this instead:
rmdir /s /q "$HOME\Desktop\bookstore-api"
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir "$HOME\Desktop" (Windows). You should no longer see bookstore-api listed.
Nice Work!
Nice Work!
You just built a fully functional REST API from scratch. This is the exact pattern that powers every web application you use daily.
Here's what you learned:
- Built a complete CRUD API with Express.js that handles GET, POST, PUT, and DELETE requests for a bookstore catalog.
- Used HTTP status codes (200, 201, 204, 400, 404, 500) to communicate success and failure through the request/response cycle.
- Added middleware for JSON parsing, catch-all 404 routes, and centralized error handling to make your API production-ready.
- Added search and filtering with query parameters so users can find books by author or title.
Ready to quiz yourself?