Bookstore Search with MongoDB and Docker
Build a search engine with Docker, MongoDB, DFS algorithms, and debounce.
Introduction
30 Second Summary
Think about searching for a book online. You type a few letters and results appear instantly, even matching on details you never searched for directly, like the publisher name or hidden tags.
In this project, you will build a bookstore search engine powered by MongoDB running inside a Docker container. You will implement two Depth-First Search functions that dig through nested book data to find matches a simple title search would miss.
What You'll Build
Type a search term and watch matching books appear in real time as styled cards or flat table rows, with results surfacing from deeply nested fields a basic database query cannot reach.
By the end of this project, you'll have:
- Browse 12 books as styled cards in your browser, then click a button to switch to a data grid view where a DFS function flattens nested JSON into readable table columns.
- Type into a search bar and watch results filter in real time. A reusable debounce utility batches your keystrokes into one API call every 300ms, and a DFS search function catches matches in deeply nested fields like publisher names and tags.
- Start and stop the entire MongoDB database with a single docker compose command. All 12 books and their nested data persist between restarts.
- Secret Mission: Upgrade the server-side search to match across title, author, and genre simultaneously using MongoDB's $or operator.
Are there any prerequisites?
No previous experience is needed. This guide walks you through setting up Docker Desktop, Node.js, and everything else from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Docker Compose and Project Dependencies
Before you can store or search any data, you need a database to hold it and a project to work from. This step gets both running.
Docker Compose lets you launch a full MongoDB database with a single command. You will also initialize a Node.js project and install the packages your search engine needs.
In this step, get ready to:
- Create a bookstore-search project folder with a docker-compose.yml file.
- Launch a MongoDB database inside a Docker container.
- Initialize a Node.js project and install dependencies.
Create the project folder and Docker Compose file
- 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-search
cd bookstore-search
Your project folder is ready on the Desktop. Open it in your code editor to start adding files.
- 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.
- Select the bookstore-search folder on your Desktop, then click Open.
Seeing a trust prompt?
VS Code may ask if you trust the authors of this folder. Click Yes, I trust the authors to continue.
- In the VS Code Explorer sidebar, click the New File icon (a file with a plus sign).
- Type docker-compose.yml and press Enter.
- Add the following MongoDB configuration to docker-compose.yml:
# Define the MongoDB service
services:
mongodb:
image: mongo:8.0
container_name: bookstore-mongo
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
# Declare the named volume for data persistence
volumes:
mongo-data:
What does this configuration do?
This file tells Docker Compose how to set up your MongoDB service.
- image: mongo:8.0 pulls the official MongoDB 8.0 Docker image.
- container_name: bookstore-mongo gives the container a fixed name you can reference.
- ports: - "27017:27017" maps MongoDB's default port inside the container to the same port on your machine.
- volumes: - mongo-data:/data/db links a named volume to MongoDB's data directory so your data survives container restarts.
- The volumes: section at the bottom declares the mongo-data named volume as a project-level resource.
- Save docker-compose.yml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- You should see docker-compose.yml in the VS Code Explorer sidebar.
File not appearing in the sidebar?
- Make sure you selected the bookstore-search folder when you opened VS Code, not a parent folder like Desktop.
- Check that you typed the filename as docker-compose.yml with no extra spaces.
Why isn't my docker-compose.yml showing up in VS Code?
✔️ Awesome, I've got everything!
Great. Make sure you have saved docker-compose.yml.
ⓧ I'd like to double check the full code
Here is the complete docker-compose.yml for comparison.
services:
mongodb:
image: mongo:8.0
container_name: bookstore-mongo
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
Launch MongoDB with Docker Compose
Docker Compose reads your configuration file, pulls the MongoDB image, and starts the container. All with one command.
- Make sure Docker Desktop is running on your machine. If it is not open, launch it via your search bar before continuing.
- Click Terminal in the VS Code menu bar.
- Click New Terminal.
Terminal starting location
The VS Code integrated terminal opens inside the folder you have open. It should show bookstore-search as the current directory.
- Start MongoDB by running this command:
docker compose up -d
What should I see?
Docker pulls the mongo:8.0 image the first time you run this command. You will see progress bars as layers download. This may take a minute or two depending on your internet speed.
Once complete, Docker starts the container in the background. The -d flag means "detached," so your terminal stays free for more commands.
Before running the next command, what do you think you will see if MongoDB started successfully?
- Check the container status by running:
docker compose ps
You should see a container named bookstore-mongo with a status of running.
Container not running?
- Make sure Docker Desktop is open and running. Docker commands fail silently if the Docker engine is not active.
- Confirm your terminal is inside the bookstore-search folder by running pwd. Docker Compose looks for docker-compose.yml in the current directory.
- Check for typos in docker-compose.yml. YAML is sensitive to indentation. Each level uses exactly two spaces.
Why isn't my MongoDB Docker container starting?
Initialize the Node.js project and install dependencies
MongoDB is running. Now you need a Node.js project that can talk to it. The npm tool creates a project configuration file and downloads the packages your search engine needs.
- Create a package.json file by running:
npm init -y
- You should see a new package.json file appear in your VS Code Explorer sidebar.
- Install the three packages your project needs by running:
npm install express@5.2.1 cors@2.8.6 mongodb@7.5.0
What are these packages?
- express@5.2.1 is a web framework for building APIs and serving web pages. Express.js handles routing, requests, and responses.
- cors@2.8.6 is middleware that allows cross-origin requests to your API. This is a common practice for real-world APIs.
- mongodb@7.5.0 is the official Node.js driver for connecting to MongoDB databases and running queries.
What do you expect to see inside node_modules if the install worked?
- Confirm the packages were installed by running:
ls node_modules
You should see folders including express, cors, and mongodb among many others. The extra folders are dependencies that those three packages need.
Missing packages or errors during install?
- Make sure you are in the bookstore-search folder. Run pwd to check your current directory.
- If you see network errors, check your internet connection and try the install command again.
- If you see permission errors on macOS, try prefixing the command with sudo. On Windows, run your terminal as Administrator.
Why did my npm install fail?
Your database is running and your project is wired up with all three packages. Next, you will seed MongoDB with book data and build an API to serve it.
Seed Nested Book Data and Serve Through an API
Your MongoDB container is running, but the database is empty. A search engine needs data to search through.
In this step, you'll write a seed script that fills the database with 12 books. Each book has a nested details object containing the publisher, page count, and tags. Then you'll build an Express.js server that exposes this data as a JSON API.
In this step, get ready to:
- Seed MongoDB with 12 nested book documents.
- Build an Express API that returns book data from the database.
- Verify the full pipeline from Docker container to browser.
Create the seed script
The seed script connects to your Dockerized MongoDB, clears any old data, and inserts a catalog of books. Each book has top-level fields like title and author, plus a nested details object that the DFS algorithm will need to traverse later.
- Create a new file called seed.js in your bookstore-search folder. Start the file with the MongoDB connection and the first two books by pasting this code:
const { MongoClient } = require('mongodb'); // MongoDB driver
const uri = 'mongodb://localhost:27017';
const client = new MongoClient(uri);
const books = [
{
title: 'The Great Gatsby',
author: 'F. Scott Fitzgerald',
genre: 'Classic Fiction',
year: 1925,
description: 'A tale of wealth, love, and the American Dream in the Jazz Age.',
details: {
pages: 180,
publisher: 'Scribner',
tags: ['american literature', 'jazz age', 'tragedy']
}
},
{
title: 'To Kill a Mockingbird',
author: 'Harper Lee',
genre: 'Classic Fiction',
year: 1960,
description: 'A story of racial injustice and childhood innocence in the American South.',
details: {
pages: 281,
publisher: 'J. B. Lippincott & Co.',
tags: ['civil rights', 'coming of age', 'legal drama']
}
},
What is the nested details object?
Each book has flat fields (title, author, genre, year, description) plus a details object one level deeper. Inside details you'll find pages (a number), publisher (a string), and tags (an array of strings).
This nesting is intentional. A simple database query can match flat fields like title, but reaching into details.publisher or individual tags requires something more. That's where the Depth-First Search algorithm comes in later.
The full catalog has 12 books, all following the same structure. Add the remaining 10 entries to the books array right after the second book.
- Add these two books below the last entry in the array:
{
title: 'Dune',
author: 'Frank Herbert',
genre: 'Science Fiction',
year: 1965,
description: 'An epic saga of politics, religion, and ecology on a desert planet.',
details: {
pages: 412,
publisher: 'Chilton Books',
tags: ['space opera', 'ecology', 'politics']
}
},
{
title: 'Neuromancer',
author: 'William Gibson',
genre: 'Science Fiction',
year: 1984,
description: 'A washed-up hacker is hired for the ultimate hack in cyberspace.',
details: {
pages: 271,
publisher: 'Ace Books',
tags: ['cyberpunk', 'artificial intelligence', 'hacking']
}
},
- Continue with these two books:
{
title: 'Pride and Prejudice',
author: 'Jane Austen',
genre: 'Romance',
year: 1813,
description: 'A witty exploration of manners, marriage, and morality in Regency England.',
details: {
pages: 279,
publisher: 'T. Egerton',
tags: ['regency', 'social commentary', 'romance']
}
},
{
title: 'The Hobbit',
author: 'J.R.R. Tolkien',
genre: 'Fantasy',
year: 1937,
description: 'A reluctant hobbit embarks on an unexpected journey with dwarves and a wizard.',
details: {
pages: 310,
publisher: 'George Allen & Unwin',
tags: ['adventure', 'dragons', 'quest']
}
},
- Add the next pair:
{
title: 'Brave New World',
author: 'Aldous Huxley',
genre: 'Science Fiction',
year: 1932,
description: 'A dystopian society where happiness is manufactured and individuality is suppressed.',
details: {
pages: 311,
publisher: 'Chatto & Windus',
tags: ['dystopia', 'social control', 'technology']
}
},
{
title: 'The Catcher in the Rye',
author: 'J.D. Salinger',
genre: 'Classic Fiction',
year: 1951,
description: 'A teenage boy navigates alienation and identity in post-war New York City.',
details: {
pages: 234,
publisher: 'Little, Brown and Company',
tags: ['coming of age', 'alienation', 'identity']
}
},
- Add the next two books:
{
title: 'Fahrenheit 451',
author: 'Ray Bradbury',
genre: 'Science Fiction',
year: 1953,
description: 'A future society where books are banned and firemen burn any that are found.',
details: {
pages: 158,
publisher: 'Ballantine Books',
tags: ['censorship', 'dystopia', 'firefighters']
}
},
{
title: 'The Midnight Library',
author: 'Matt Haig',
genre: 'Contemporary Fiction',
year: 2020,
description: 'A woman discovers a library between life and death where she can try alternate lives.',
details: {
pages: 288,
publisher: 'Penguin Books',
tags: ['parallel lives', 'mental health', 'philosophy']
}
},
- Add the last two books and close the array with ];:
{
title: 'Project Hail Mary',
author: 'Andy Weir',
genre: 'Science Fiction',
year: 2021,
description: 'A lone astronaut must save Earth from an extinction-level threat.',
details: {
pages: 476,
publisher: 'Ballantine Books',
tags: ['space exploration', 'survival', 'first contact']
}
},
{
title: 'Educated',
author: 'Tara Westover',
genre: 'Memoir',
year: 2018,
description: 'A woman raised in a survivalist family in Idaho fights for her own education.',
details: {
pages: 334,
publisher: 'Random House',
tags: ['memoir', 'education', 'resilience']
}
}
];
- Below the closing ]; of the books array, add the seed function that connects to MongoDB, clears old data, and inserts the books:
async function seed() {
try {
// Connect to the MongoDB container
await client.connect();
console.log('Connected to MongoDB');
const db = client.db('bookstore');
const collection = db.collection('books');
// Clear old data and insert fresh books
await collection.deleteMany({});
console.log('Cleared existing books');
const result = await collection.insertMany(books);
console.log(`Inserted ${result.insertedCount} books`);
} finally {
await client.close();
}
}
seed();
What does the seed function do?
- client.connect() opens a connection to MongoDB running in your Docker container on port 27017.
- client.db('bookstore') creates (or connects to) a database called bookstore. MongoDB creates databases and collections automatically when you first write to them.
- deleteMany({}) clears all existing documents so you can re-run the script without duplicating data.
- insertMany(books) inserts all 12 book objects at once and returns a result with insertedCount.
- The finally block ensures the connection closes even if something goes wrong.
- Save seed.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved seed.js before continuing.
ⓧ I'd like to double check the full code
Your seed.js file is long, so scroll through it from top to bottom and confirm these sections appear in order:
- The require('mongodb') import, uri, and client setup at the top.
- const books = [ opening the array.
- 12 book objects, each with title, author, genre, year, description, and a nested details object.
- ]; closing the array.
- The async function seed() with connect, deleteMany, insertMany, and close.
- seed(); at the very bottom.
Before you run the seed script, what do you think the terminal will output?
- Run the seed script by running this command:
node seed.js
You should see three lines of output:
Connected to MongoDB
Cleared existing books
Inserted 12 books
Seeing a connection error?
- Make sure your MongoDB container is still running. Run docker compose ps and confirm bookstore-mongo shows as running. If it stopped, run docker compose up -d to restart it.
- Check that port 27017 is not in use by another MongoDB instance on your machine.
- If you see a syntax error, scroll through seed.js and check for missing commas between book objects or unmatched curly braces in the details objects.
Why isn't my seed script connecting to MongoDB?
Build the Express server
With data in the database, you need a way to serve it. An Express server will connect to MongoDB and expose the book data through a REST API endpoint that any frontend can call.
- Before creating the server, create an empty public folder in your bookstore-search directory by running:
mkdir public
- Confirm the folder exists by running:
ls
You should see public listed alongside docker-compose.yml, node_modules, package.json, and seed.js.
- Create a new file called server.js in your bookstore-search folder. Add the imports, app setup, and the books endpoint by pasting this code:
const express = require('express');
const cors = require('cors');
const { MongoClient } = require('mongodb');
const path = require('path');
const app = express();
const port = 3000;
const uri = 'mongodb://localhost:27017';
const client = new MongoClient(uri);
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
// Return all books from the database
app.get('/api/books', async (req, res) => {
const db = client.db('bookstore');
const books = await db.collection('books').find({}).toArray();
res.json(books);
});
What does this code do?
- cors() enables cross-origin requests. Strictly optional since the frontend and API share the same origin, but it's good API hygiene for real-world projects.
- express.static serves files from the public folder. You created this folder empty for now. The frontend goes there in the next step.
- The GET /api/books route connects to the bookstore database, runs find({}) to get all documents, converts the cursor to an array with .toArray(), and sends the result as JSON.
- Below the route, add the start function that connects to MongoDB and launches the server:
// Connect to MongoDB then start listening
async function start() {
await client.connect();
console.log('Connected to MongoDB');
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
}
start();
Why call client.connect() explicitly?
The MongoDB Node.js driver can auto-connect on the first operation. Calling connect() explicitly in the start() function gives you fail-fast behaviour. If the MongoDB container isn't running, the server crashes immediately with a clear error instead of silently failing on the first API request.
- Save server.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js before continuing.
ⓧ I'd like to double check the full code
const express = require('express');
const cors = require('cors');
const { MongoClient } = require('mongodb');
const path = require('path');
const app = express();
const port = 3000;
const uri = 'mongodb://localhost:27017';
const client = new MongoClient(uri);
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/books', async (req, res) => {
const db = client.db('bookstore');
const books = await db.collection('books').find({}).toArray();
res.json(books);
});
async function start() {
await client.connect();
console.log('Connected to MongoDB');
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
}
start();
Start the server and test the API
- Start the Express server by running:
node server.js
What should I see?
The terminal prints two lines:
Connected to MongoDB
Server running at http://localhost:3000
The server takes over this terminal window. Leave it running and don't press Ctrl+C yet.
What do you expect to see when you visit the API endpoint in your browser?
- Open your browser and navigate to:
http://localhost:3000/api/books
You should see a JSON array of 12 book objects. Each one contains a nested details object with pages, publisher, and tags fields.
Not seeing JSON in the browser?
- Make sure the terminal still shows the server running. If you see an error or the process ended, check the message. A port-in-use error means another process is on port 3000. Stop the other process or change the port value in server.js.
- Double check the URL is exactly http://localhost:3000/api/books with no typos.
- If the page loads but shows an empty array [], run node seed.js again in a new terminal tab to re-populate the database.
Why is my Express server not returning book data?
Your database is loaded and your API is live. Next up, you'll build a frontend that turns this raw JSON into something people actually want to look at.
Build the Dual-View Frontend with DFS Flattening
Your API is serving book data as JSON, but visiting http://localhost:3000/api/books shows a wall of raw text. Nobody would browse a bookstore that way.
In this step, you will build two ways to view the same data: a card grid that makes books browsable, and a spreadsheet-style data table. The data table is the interesting one. Each book has a nested details object containing publisher, page count, and tags. A Depth-First Search function will walk every branch of that nested JSON tree and flatten it into columns the table can display.
In this step, get ready to:
- Create an HTML page with styled card and table view layouts.
- Build a DFS function to flatten nested JSON into table columns.
- Wire up data loading, display rendering, and a view toggle button.
Create the public folder and page structure with styles
Your server.js already has express.static middleware pointing at a public folder. That folder does not exist yet, so the middleware has nothing to serve. Once you create the folder and put an HTML file inside it, the server will start serving your frontend automatically.
- In your code editor's Explorer sidebar, right-click inside the bookstore-search folder.
- Select New Folder and name it public.
- Right-click the new public folder and select New File. Name it index.html.
You should see public/index.html appear in the sidebar, nested under the public folder.
- Start the HTML page by pasting the following into public/index.html. This is the head section, including all the CSS styles for both views:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookstore Search</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f0f4f8; color: #1a202c; padding: 2rem; }
h1 { text-align: center; margin-bottom: 1.5rem; font-size: 2rem; color: #2d3748; }
.controls { max-width: 600px; margin: 0 auto 1.5rem; display: flex; gap: 0.75rem; align-items: center; }
.toggle-btn { padding: 0.75rem 1rem; font-size: 0.9rem; border: 2px solid #4299e1; border-radius: 8px; background: white; color: #4299e1; cursor: pointer; white-space: nowrap; transition: background 0.2s, color 0.2s; }
.toggle-btn:hover { background: #4299e1; color: white; }
.book-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; max-width: 1200px; margin: 0 auto; }
.book-card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: transform 0.2s, box-shadow 0.2s; }
.book-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
.book-card h2 { font-size: 1.2rem; margin-bottom: 0.5rem; color: #2d3748; }
.book-card .author { color: #718096; font-size: 0.9rem; margin-bottom: 0.75rem; }
.book-card .meta { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
.book-card .genre, .book-card .year { font-size: 0.8rem; padding: 0.25rem 0.5rem; border-radius: 6px; background: #edf2f7; color: #4a5568; }
.book-card .description { font-size: 0.9rem; color: #4a5568; line-height: 1.5; }
.table-container { max-width: 1200px; margin: 0 auto; overflow-x: auto; display: none; }
.data-table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.data-table th { background: #2d3748; color: white; padding: 0.75rem 1rem; text-align: left; font-size: 0.85rem; white-space: nowrap; }
.data-table td { padding: 0.6rem 1rem; font-size: 0.85rem; border-bottom: 1px solid #e2e8f0; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table tr:nth-child(even) { background: #f7fafc; }
.data-table tr:hover { background: #edf2f7; }
.no-results { text-align: center; color: #a0aec0; font-size: 1.1rem; padding: 3rem; }
</style>
</head>
What do these styles do?
The CSS defines two complete view layouts. .book-card styles create rounded white cards with a hover lift effect, each showing a title, author, genre badge, year badge, and description.
The .data-table styles create a spreadsheet-style grid with dark column headers, alternating row colors, and horizontal scroll for wide content. The .table-container starts with display: none so the card view is visible first.
- Add the body section directly below the closing </head> tag:
<body>
<h1>Bookstore Search</h1>
<div class="controls">
<button class="toggle-btn" id="toggleView">Grid View</button>
</div>
<div class="book-grid" id="bookGrid"></div>
<div class="table-container" id="tableContainer">
<table class="data-table" id="dataTable"></table>
</div>
<div class="no-results" id="noResults" style="display: none;">No books found.</div>
</body>
</html>
What are these elements for?
- toggleView is the button that switches between card and table views.
- bookGrid is the container for book cards. tableContainer wraps the data table. JavaScript will populate both with book data.
- noResults is a hidden message that appears when no books match a future search query.
- Save public/index.html.
- Open your browser and visit http://localhost:3000 (not the /api/books path from before). You should see a light-grey page with a "Bookstore Search" heading and a blue-outlined "Grid View" button.
No books appear yet because there is no JavaScript to fetch and display them. The styled containers are ready and waiting.
Seeing a blank page or "Cannot GET /"?
- Make sure the file is saved as public/index.html inside the bookstore-search folder. The public folder must be at the same level as server.js.
- Confirm the server is still running. If it stopped, restart it by running node server.js in your terminal.
- Check that you are visiting http://localhost:3000 with no trailing path.
Why isn't my HTML page showing up at localhost:3000?
Add the DFS flattening function for nested JSON
Each book has a flat set of fields like title and author, but also a nested details object containing pages, publisher, and tags. A flat HTML table cannot render nested objects as columns. It needs every value at the top level.
What is Depth-First Search on JSON?
A JSON object is a tree. The top-level keys are branches, and nested objects are sub-branches. DFS walks down each branch as deep as it goes before backing up and trying the next one.
The dfsFlat function uses this traversal to collect every leaf value. When it encounters a nested object, it recurses deeper. When it finds a primitive value or array, it records it with a dot-notated key like details.publisher. The result is a flat object the table can use directly as column values.
- In public/index.html, find the closing </div> for the noResults div. Add a script tag with DOM references, state variables, and the dfsFlat function below it:
<script>
// --- DOM element references ---
const bookGrid = document.getElementById('bookGrid');
const tableContainer = document.getElementById('tableContainer');
const dataTable = document.getElementById('dataTable');
const noResults = document.getElementById('noResults');
const toggleView = document.getElementById('toggleView');
// --- State tracking ---
let currentView = 'cards';
let currentBooks = [];
// --- DFS: Flatten nested JSON for table rows ---
function dfsFlat(obj, prefix) {
const result = {};
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
const fullKey = prefix ? prefix + '.' + key : key;
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const nested = dfsFlat(value, fullKey);
for (const nk in nested) { result[nk] = nested[nk]; }
} else if (Array.isArray(value)) {
result[fullKey] = value.join(', ');
} else {
result[fullKey] = value;
}
}
return result;
}
How does dfsFlat work?
- It loops through every key in the object. If a value is another object, it recurses with a dot-separated prefix (e.g. details.publisher).
- Arrays get joined into a comma-separated string (e.g. "space opera, ecology, politics").
- Primitive values (strings, numbers) are stored as-is. The result is a completely flat object with no nesting.
Wire up data loading, display, and the view toggle
The dfsFlat function is ready but nothing calls it yet. You need functions to fetch book data from the API, render cards, build the flattened table, and toggle between the two views.
- Add the data loading and display functions below the dfsFlat function:
// --- Flatten nested data into table rows ---
function displayTable(books) {
const flatBooks = books.map(book => { const copy = {...book}; delete copy._id; return dfsFlat(copy, ''); });
const headers = Object.keys(flatBooks[0]);
const headerRow = '<tr>' + headers.map(h => `<th>${h}</th>`).join('') + '</tr>';
const bodyRows = flatBooks.map(fb => '<tr>' + headers.map(h => `<td>${fb[h] !== undefined ? fb[h] : ''}</td>`).join('') + '</tr>').join('');
dataTable.innerHTML = '<thead>' + headerRow + '</thead><tbody>' + bodyRows + '</tbody>';
}
// --- Load books from the API ---
async function loadBooks() {
const response = await fetch('/api/books');
const books = await response.json();
currentBooks = books;
displayBooks(books);
}
// --- Route books to both card and table views ---
function displayBooks(books) {
if (books.length === 0) {
bookGrid.innerHTML = '';
dataTable.innerHTML = '';
noResults.style.display = 'block';
return;
}
noResults.style.display = 'none';
displayCards(books);
displayTable(books);
}
What do these functions do?
- displayTable copies each book, removes the MongoDB _id field, then runs dfsFlat to flatten the nested data. It reads the headers from the first flattened book and builds HTML rows for each one.
- loadBooks fetches all books from /api/books and passes them to displayBooks.
- displayBooks is the router. If there are no books, it shows the "No books found" message. Otherwise it calls both displayCards and displayTable so both views are populated even though only one is visible at a time.
- Add the card rendering function, the view toggle listener, and the initial loadBooks() call below the displayBooks function. This chunk also closes the script, body, and html tags:
// --- Render book cards ---
function displayCards(books) {
bookGrid.innerHTML = books.map(book => `
<div class="book-card">
<h2>${book.title}</h2>
<div class="author">by ${book.author}</div>
<div class="meta"><span class="genre">${book.genre}</span><span class="year">${book.year}</span></div>
<div class="description">${book.description}</div>
</div>`).join('');
}
// --- View toggle ---
toggleView.addEventListener('click', () => {
if (currentView === 'cards') {
currentView = 'grid';
bookGrid.style.display = 'none';
tableContainer.style.display = 'block';
toggleView.textContent = 'Card View';
} else {
currentView = 'cards';
bookGrid.style.display = 'grid';
tableContainer.style.display = 'none';
toggleView.textContent = 'Grid View';
}
});
loadBooks();
</script>
</body>
</html>
What does this code do?
- displayCards maps each book into a card HTML template with the title, author, genre badge, year badge, and description.
- The toggle listener swaps which container is visible. It hides bookGrid and shows tableContainer (or vice versa) and updates the button label to match the current view.
- loadBooks() at the bottom fires immediately when the page loads, fetching all 12 books and populating both views.
Before you refresh, what do you expect to see at http://localhost:3000 now? Think about what loadBooks() does when the page first loads.
- Save public/index.html.
- Refresh http://localhost:3000 in your browser.
You should see 12 book cards arranged in a responsive grid. Each card shows the book's title, author, genre badge, year badge, and a short description.
- Click the Grid View button.
The cards disappear and a data table takes their place. Scroll right to see all columns. The DFS flattening function turned the nested details object into separate columns: details.pages, details.publisher, and details.tags. The button label now reads "Card View".
- Click Card View to toggle back. The cards return.
Not seeing any books?
- Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for red error messages. A common cause is a typo in a function name like displayBooks or dfsFlat.
- Make sure the server is still running. If the terminal shows an error or the process stopped, restart it with node server.js.
- Check that loadBooks(); is the last line before </script>. Without it, nothing triggers the fetch.
My bookstore page loads but no book cards appear. Help me debug it.
✔️ Awesome, I've got everything!
Double check that you have saved public/index.html. Confirm the card view shows 12 books and the data grid shows flattened columns.
ⓧ I'd like to double check the full code
Since public/index.html is a long file, scroll through your code and confirm these sections are present in order:
- The <head> section with all CSS styles (21 rules from * reset through .no-results).
- The <body> with the heading, controls div, bookGrid div, tableContainer div, and noResults div.
- Inside the <script> tag: DOM references, state variables, then dfsFlat.
- displayTable, loadBooks, and displayBooks functions.
- displayCards function, toggleView event listener, and the loadBooks(); call at the very end.
Your bookstore now has two polished views. You can browse cards or scan a spreadsheet. But try finding a specific book. With 12 entries you can scan visually, but imagine 1,200. In the next step, you will add a search bar with a reusable debounce function and a DFS search algorithm that digs through every nested field to find matches.
Add Debounced Search with DFS JSON Traversal
Your bookstore has two polished views for browsing all 12 books. The problem is clear: there is no way to find a specific book without visually scanning every card or row.
In this step, you will add a search bar that filters books in real time. A reusable debounce function prevents excessive API calls, and a Depth-First Search (DFS) algorithm recursively traverses each book's nested JSON to find matches the server-side query misses.
In this step, get ready to:
- Add a server-side search endpoint that uses MongoDB's $regex operator to match book titles.
- Build a search bar with a reusable debounce utility that batches keystrokes into a single call every 300ms.
- Implement a DFS function that recursively walks each book's entire JSON tree to find matches in deeply nested fields.
Add a server-side search endpoint
The /api/books endpoint returns every book in the database. To search, you need a separate endpoint that accepts a query string and returns only matching results.
- In your code editor, open server.js.
- Find the closing }); of the existing app.get('/api/books') route.
- Add this new search route directly below it:
app.get('/api/books/search', async (req, res) => {
// Read the search term from the URL query string
const query = req.query.q;
if (!query) {
return res.json([]);
}
// Use MongoDB $regex for case-insensitive title matching
const db = client.db('bookstore');
const books = await db.collection('books')
.find({ title: { $regex: query, $options: 'i' } })
.toArray();
res.json(books);
});
What does this code do?
This route reads a q parameter from the URL (e.g. /api/books/search?q=dune). If no query is provided, it returns an empty array right away.
The $regex operator tells MongoDB to match patterns instead of exact strings. The $options: 'i' flag makes the match case-insensitive, so searching "dune" matches "Dune".
- Save server.js.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Restart the server by running:
node server.js
Before you test, what do you expect to see if you visit /api/books/search?q=dune in the browser? What about /api/books/search?q=penguin?
- Open your browser and visit:
http://localhost:3000/api/books/search?q=dune
You should see a JSON array containing one book: Dune by Frank Herbert.
- Now try searching for a publisher name by visiting:
http://localhost:3000/api/books/search?q=penguin
You get an empty array. "Penguin Books" is stored inside the nested details.publisher field, and this endpoint only searches the title field. Keep this gap in mind. You will close it later in this step with DFS.
Seeing an error when you visit the URL?
- Make sure you saved server.js and restarted the server with Ctrl+C followed by node server.js.
- Check that the new route is placed after the existing /api/books route and before the start() function.
Why isn't my search endpoint returning results?
✔️ Awesome, I've got everything!
Great. Make sure you have saved server.js and your server is running.
ⓧ I'd like to double check the full code
Your server.js should now have two routes. Scroll through the file and confirm:
- The original app.get('/api/books') route is still present.
- The new app.get('/api/books/search') route sits directly below it, before the start() function.
- The search route reads req.query.q, returns an empty array when no query is given, and uses { $regex: query, $options: 'i' } on the title field.
Add the search bar and debounce utility
The server endpoint is ready. Now you need a search bar in the UI. Firing a network request on every single keystroke would flood the server. A debounce function solves this by waiting until the user pauses typing before making the call.
- Switch back to public/index.html in your code editor.
- In the <style> section, find the line that starts with .controls {.
- Add these two CSS rules directly below it:
.search-input { flex: 1; padding: 0.75rem 1rem; font-size: 1rem; border: 2px solid #e2e8f0; border-radius: 8px; outline: none; transition: border-color 0.2s; }
.search-input:focus { border-color: #4299e1; }
- Scroll down to the HTML body. Find the <div class="controls"> block.
- Add this <input> element inside the controls div, directly above the <button> element:
<input type="text" class="search-input" id="searchInput" placeholder="Search books..." />
- Save index.html and refresh http://localhost:3000 in your browser.
You should see a search bar appear next to the view toggle button. Typing in it does nothing yet.
Don't see the search bar?
- Make sure the <input> element is inside the <div class="controls"> block, not outside it.
- Confirm the CSS rules for .search-input are placed after the .controls rule, not inside another rule block.
Why isn't my search bar showing up in the browser?
- Back in the <script> section, find this line:
const noResults = document.getElementById('noResults');
- Add this line directly below it, before the existing toggleView line:
const searchInput = document.getElementById('searchInput');
- Now find the line let currentBooks = []; and add the debounce utility function directly below it, above the existing dfsFlat function:
// --- Debounce utility ---
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
What is a debounce function?
Imagine typing "dune" quickly. Without debounce, that fires four separate searches: "d", "du", "dun", "dune". Each one hits the server.
The debounce function wraps any function with a timer. Each call resets the timer. Only when the timer expires (after the specified delay with no new calls) does the wrapped function actually run. This turns four rapid keystrokes into one search.
- Save index.html.
The search bar is visible and the debounce utility is ready. The next substep connects everything with the DFS search function.
Implement DFS search and connect everything
The server-side search only checks the title field. Publisher names, tags, and other data are buried inside nested details objects. A DFS function can walk every branch of the JSON tree and find matches at any depth.
- In index.html, find the closing } of the dfsFlat function.
- Add the dfsSearch function directly below it:
// --- DFS: Search through all values in a JSON object ---
function dfsSearch(obj, query) {
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
const value = obj[key];
// If the value is a nested object, recurse deeper
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
if (dfsSearch(value, query)) return true;
} else if (Array.isArray(value)) {
// Check each element in the array
for (const item of value) {
if (typeof item === 'object' && item !== null) {
if (dfsSearch(item, query)) return true;
} else if (String(item).toLowerCase().includes(query)) {
return true;
}
}
} else if (String(value).toLowerCase().includes(query)) {
return true;
}
}
return false;
}
How does DFS walk the JSON tree?
Each book is a tree of nested data. The function starts at the root and checks every key. If a value is a plain object, it recurses deeper. If it is an array, it iterates each element and recurses or checks. If it is a primitive (string, number), it converts to a lowercase string and checks for a match.
This means searching "penguin" reaches into details.publisher and matches "Penguin Books". The server-side $regex on title misses it entirely.
- Save index.html and refresh the browser.
- Open your browser console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Test the DFS function by pasting this into the console:
dfsSearch({details: {publisher: "Penguin Books"}}, "penguin")
You should see true printed. The DFS reached into the nested object and found the match.
- Now find the existing loadBooks function in the script. Add the handleSearch function directly below it:
async function handleSearch(query) {
if (!query) {
loadBooks();
return;
}
// Fetch all books and filter client-side with DFS
const response = await fetch('/api/books');
const allBooks = await response.json();
const lowerQuery = query.toLowerCase();
const filtered = allBooks.filter(book => dfsSearch(book, lowerQuery));
currentBooks = filtered;
displayBooks(filtered);
}
What does handleSearch do?
When the search bar is empty, it reloads all 12 books. Otherwise, it fetches every book from /api/books and filters them client-side using dfsSearch. This catches matches in deeply nested fields like details.publisher and details.tags.
- Scroll to the bottom of the script. Find the closing }); of the toggleView event listener.
- Add the debounced search wiring directly below it, above the final loadBooks(); call:
// --- Debounced search input ---
const debouncedSearch = debounce((query) => { handleSearch(query); }, 300);
searchInput.addEventListener('input', () => { debouncedSearch(searchInput.value.trim()); });
What does this wiring do?
The debounce call wraps handleSearch with a 300ms delay. Every keystroke resets the timer. Only after the user pauses for 300ms does the actual search fire.
The event listener on searchInput passes the trimmed input value into the debounced function on every input event.
- Save index.html and refresh http://localhost:3000 in your browser.
Earlier, the server returned an empty array when you searched for "penguin". Do you think the result will be different now that DFS is running on the client?
- Type dune in the search bar.
- Only Dune by Frank Herbert should appear in both the card and table views.
- Clear the search bar.
- All 12 books should return.
- Type penguin in the search bar.
This time, The Midnight Library appears. The DFS function reached into the nested details.publisher field and matched "Penguin Books". The server-side title-only search missed it, but DFS found it by walking the entire JSON tree.
- Open the browser console again with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Click the Network tab.
- Clear the search bar and type a word quickly.
You should see only one or two network requests fire, not one per keystroke. The debounce function is batching your input into a single call after you pause.
Search not filtering the books?
- Check that searchInput is declared between noResults and toggleView in the DOM references at the top of the script.
- Make sure the dfsSearch function is placed after dfsFlat and before loadBooks.
- Confirm the wiring lines (debouncedSearch and searchInput.addEventListener) are placed after the toggleView listener and before the final loadBooks(); call.
Why isn't my DFS search finding nested matches?
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html.
ⓧ I'd like to double check the full code
Your index.html has grown with several additions. Scroll through your script and confirm these pieces are present, in this order:
- DOM references: bookGrid, tableContainer, dataTable, noResults, searchInput, toggleView.
- The debounce function (above dfsFlat).
- The dfsFlat function (from the previous step).
- The dfsSearch function (below dfsFlat).
- The loadBooks function, then handleSearch directly below it.
- The display functions: displayBooks, displayCards, displayTable.
- The toggleView listener, then the debouncedSearch wiring and searchInput listener, and finally loadBooks(); at the very end.
In the HTML, confirm the <input> element with id searchInput sits inside the controls div, before the toggle button.
Your bookstore search engine is fully functional. You can search by title, author, genre, or any value buried deep in the nested data. Next up, take on the Secret Mission to upgrade the server-side search so it catches more than just titles.
Secret mission
Upgrade Server Search with MongoDB's $or Operator
The server-side search only checks book titles. Try searching "tolkien" and nothing comes back, even though The Hobbit is right there in the database. Your mission: upgrade the search endpoint to check title, author, and genre simultaneously using MongoDB's $or operator, then merge server results with client-side DFS matches so nothing slips through.
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 Docker container (bookstore-mongo) running MongoDB with a named volume (bookstore-search_mongo-data) storing the book data.
- The bookstore-search project folder on your Desktop containing all project files and node_modules.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your search engine.
- The Express server stops automatically when you close the terminal window it was running in.
- The MongoDB container stays running in the background until you stop Docker Desktop or explicitly stop the container.
- To restart the Express server later, open a terminal in your bookstore-search folder and run node server.js.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and database data stay intact so you can pick up where you left off.
- If the Express server is still running, press Ctrl+C in its terminal window to stop it.
- Stop the MongoDB container by running this command from your bookstore-search folder:
docker compose stop
- When you are ready to come back, restart the container by running:
docker compose start
- Your book data is preserved in the named volume, so you do not need to re-seed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the Docker container, the named volume with your book data, and the project folder.
- If the Express server is still running, press Ctrl+C in its terminal window to stop it.
- Stop the MongoDB container and delete the named volume by running this command from your bookstore-search folder:
docker compose down -v
- This removes the bookstore-mongo container and the bookstore-search_mongo-data volume. All stored book data is deleted. You can always re-seed later with node seed.js if you recreate the container.
- Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf bookstore-search
- On Windows, run:
cd ~\Desktop
rmdir /s /q bookstore-search
Nice Work!
Nice Work!
Nice work! You just built a full-stack bookstore search engine powered by a containerized database, two Depth-First Search algorithms, and a debounced dual-view frontend.
You've learned how to:
- Containerize a MongoDB database with Docker Compose, seed it with nested book documents, and serve the data through an Express.js REST API with $regex search.
- Build two Depth-First Search functions: dfsFlat to recursively flatten nested JSON into data grid columns, and dfsSearch to find matches buried inside deeply nested book objects.
- Wire up a reusable debounce utility that batches keystrokes into single API calls, and a dual-view frontend that toggles between styled book cards and a spreadsheet-style data grid.
- Completed an optional Secret Mission to push your search engine further.
Ready to quiz yourself?