Build a Personal Library Dashboard

Scan your book collection and build a searchable web dashboard.

Introduction

30 Second Summary

You have hundreds of books scattered across a folder. Finding a specific one means scrolling through filenames, guessing at titles, and having no idea which ones you have actually read.

In this project, you will build a local web dashboard that scans your book collection, extracts metadata from every PDF and EPUB file, and serves a searchable, filterable catalog you can browse from any device on your network.

What You'll Build

Point your dashboard at a folder of books, and within seconds every title and author appears in a dark-themed card grid you can search, filter by format, and track your reading progress on.

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

  • A working book scanner that cracks open PDFs and EPUBs, pulls out embedded titles and authors, and stores everything in a local SQLite database.
  • A real-time search and filter system that narrows hundreds of books to exactly what you are looking for as you type.
  • A persistent read/unread toggle on every book card that survives page refreshes and server restarts.
  • Secret Mission: A statistics panel showing your total collection size, reading progress, and format breakdown at a glance.

Are there any prerequisites?

You should be comfortable with basic HTML, CSS, and JavaScript. Familiarity with running terminal commands is helpful but not required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up the Project

Your dashboard depends on three libraries: Express for serving web pages, pdf-parse for reading PDF metadata, and epub-metadata for cracking open EPUB files.

In this step, you'll create the project folder, install those packages, and verify that Node.js 24's built-in SQLite module is ready to use with zero extra installation.

In this step, get ready to:
  • Create the project folder and install packages.
  • Verify Node.js built-in SQLite works.
  • Create the project file structure.
Create the project and install packages
  • Press the Windows key to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir library-dashboard
cd library-dashboard
  • Initialize the project and install the three packages by running:
npm init -y
npm install express@5.2.1 pdf-parse@2.4.5 epub-metadata@0.3.1

What do these packages do?

  • express@5.2.1 is a web framework that serves your dashboard page and handles API requests from the browser.
  • pdf-parse@2.4.5 reads the metadata embedded inside PDF files (title, author).
  • epub-metadata@0.3.1 does the same for EPUB ebook files.

You should see npm download the packages and print a summary showing 3 packages added.

Seeing permission errors or EACCES?

Make sure you are running the terminal as a regular user, not as Administrator. Close the terminal, reopen it with Ctrl+`, and try the install command again.

help me fix npm permission errors on Windows

  • Now open the library-dashboard folder in VS Code so the workspace is rooted correctly. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the library-dashboard folder, and click Select Folder.

VS Code reloads with library-dashboard as the workspace root. You should see package.json and a node_modules folder in the Explorer sidebar on the left.

  • Open the integrated terminal again by pressing Ctrl+`.
  • Open package.json by clicking it in the Explorer sidebar.
  • Replace its entire contents with the following code:
{
  "name": "library-dashboard",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "scan": "node scanner.js",
    "start": "node server.js"
  },
  "dependencies": {
    "epub-metadata": "0.3.1",
    "express": "5.2.1",
    "pdf-parse": "2.4.5"
  }
}

What did we change?

  • "type": "module" tells Node.js to treat .js files as ES modules. This lets you use modern import/export syntax instead of require().
  • The scripts section adds shortcuts so you can run npm run scan and npm start later.
  • Save the file by pressing Ctrl+S.
Verify Node.js built-in SQLite works

Node.js v24.18.0 ships with a built-in SQLite module. This means you get a full database engine without installing anything extra.

  • Before you run this, what do you think will happen? If the SQLite module is available, the script should create an in-memory database and print a success message.
  • Confirm SQLite works by running this command in your terminal:
node -e "const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE test (id INTEGER)'); console.log('SQLite ready')"

You should see SQLite ready printed in the terminal.

Why require() instead of import here?

The node -e flag runs a one-line script directly in the terminal. One-liner scripts use require() because ES module import statements require a file context. In your actual project files, you'll use import syntax.

Seeing "Cannot find module 'node:sqlite'"?

This means your Node.js version is too old. Run node --version to check. You need v22.5.0 or higher for the built-in SQLite module. Update Node.js from the official site at nodejs.org.

help me update Node.js to a version that supports built-in SQLite

Create the project file structure

Your project needs a public folder for browser-facing files and two root-level scripts for the scanner and server.

  • Create the folder structure by running these commands in your terminal:
mkdir public
New-Item scanner.js -ItemType File
New-Item server.js -ItemType File
New-Item public/index.html -ItemType File
New-Item public/style.css -ItemType File
New-Item public/app.js -ItemType File

What is this file structure for?

  • scanner.js will read your book folder and extract metadata into the database.
  • server.js will run the web server that serves your dashboard and API.
  • public/ holds the HTML, CSS, and JavaScript that runs in the browser.
  • Confirm the files exist by running:
ls

You should see node_modules, package.json, package-lock.json, public, scanner.js, and server.js listed in the output.

  • Verify the public folder contents by running:
ls public

You should see app.js, index.html, and style.css listed.

Don't see all the files?

Make sure you ran the mkdir public command before creating files inside it. If the public folder is missing, run mkdir public first, then re-run the New-Item commands for the three public files.

help me create the project file structure

✔️ Awesome, I've got everything!

Great. Double check you've saved package.json and that all files appear in the VS Code Explorer sidebar.

ⓧ I'd like to double check the full code

The only file with content at this point is package.json. It should look exactly like this:

{
  "name": "library-dashboard",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "scan": "node scanner.js",
    "start": "node server.js"
  },
  "dependencies": {
    "epub-metadata": "0.3.1",
    "express": "5.2.1",
    "pdf-parse": "2.4.5"
  }
}

All other files (scanner.js, server.js, public/index.html, public/style.css, public/app.js) should exist but be empty.

Your project skeleton is ready. Next up, you'll write the book scanner that cracks open your PDFs and EPUBs to pull out titles and authors.

Build the Book Scanner

Your books folder has hundreds of PDF and EPUB files sitting as bare filenames. You can scroll through the folder, but you can't see which author wrote what, or search by title without opening each file individually.

In this step, you'll build a scanner that cracks open each file, extracts the embedded title and author metadata, and stores it all in a SQLite database. Once the data is queryable, the later steps can display it however you want.

In this step, get ready to:
  • Write metadata extraction functions for PDF and EPUB files.
  • Store book data in a SQLite database using Node.js 24's built-in module.
  • Run the scanner and populate your library.
Write the scanner that extracts metadata and saves to SQLite

The scanner lives in a single file that handles three jobs: connecting to the database, extracting metadata from files, and looping through the folder to process each book.

  • Open scanner.js in VS Code (it's currently empty).
  • Add the imports and database setup by pasting the following code:
import { DatabaseSync } from 'node:sqlite';
import { readdir, readFile } from 'node:fs/promises';
import { join, extname, basename } from 'node:path';
import { PDFParse } from 'pdf-parse';
import { readEpubMetadata } from 'epub-metadata';

const BOOKS_DIR = 'C:/Users/YourName/Books';

const db = new DatabaseSync('./library.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT,
    format TEXT NOT NULL,
    filename TEXT NOT NULL UNIQUE,
    filepath TEXT NOT NULL,
    is_read INTEGER DEFAULT 0
  )
`);

What does this code do?

The first five lines import everything the scanner needs: the built-in SQLite module, file-system helpers, path utilities, and the two metadata-parsing libraries you installed in Step 1.

Then it opens (or creates) a database file called library.db in your project folder and runs a CREATE TABLE statement. The IF NOT EXISTS clause means re-running the scanner won't destroy existing data.

  • Save scanner.js.
  • Confirm the database gets created by running this command in your terminal:
node scanner.js
  • Verify the database file exists by running:
ls

You should see library.db in the output alongside your other project files.

Don't see library.db?

  • Make sure you saved scanner.js before running the command.
  • Check that your package.json still has "type": "module" at the top level. Without it, the import statements will fail.
  • If you see a "Cannot find module" error for pdf-parse or epub-metadata, re-run npm install to make sure the packages are in your node_modules folder.

Still stuck? Help me debug why library.db isn't being created when I run scanner.js.

  • Add the PDF metadata extraction function below the database setup code in scanner.js:
async function extractPdfMetadata(filepath) {
  const buffer = await readFile(filepath);
  const parser = new PDFParse({ data: buffer });
  try {
    const result = await parser.getInfo();
    return {
      title: result.info?.Title || null,
      author: result.info?.Author || null,
    };
  } catch (error) {
    return { title: null, author: null };
  } finally {
    await parser.destroy();
  }
}

What does this function do?

It reads the entire PDF into memory as a buffer, then uses pdf-parse to pull the Title and Author from the PDF's info dictionary. Many PDFs lack this metadata entirely, so the function returns null for missing fields. The caller will fall back to the filename in that case.

  • Add the EPUB metadata extraction function directly below extractPdfMetadata:
async function extractEpubMetadata(filepath) {
  try {
    const metadata = await readEpubMetadata(filepath);
    const author = metadata.creator
      ? (typeof metadata.creator === 'string'
          ? metadata.creator
          : metadata.creator.text)
      : null;
    return {
      title: metadata.title || null,
      author: author,
    };
  } catch (error) {
    return { title: null, author: null };
  }
}

Why the extra check on creator?

The epub-metadata library returns the creator field as either a plain string or an object with text, file-as, and role properties. The conditional handles both shapes so the scanner won't crash on either format.

  • Save scanner.js and confirm there are no syntax errors by running:
node scanner.js

The command should exit quietly with no errors. You won't see new output because the two functions are defined but not called yet.

Seeing a syntax error?

  • Check that you placed each function after the db.exec(...) block, not inside it.
  • Make sure both functions have their opening and closing curly braces matched. A missing } will produce an "Unexpected end of input" error.

Need help? Help me fix a syntax error in my scanner.js metadata functions.

  • Now add the first part of the main scanning function below extractEpubMetadata:
async function scanBooks() {
  const files = await readdir(BOOKS_DIR);
  const bookFiles = files.filter(file => {
    const ext = extname(file).toLowerCase();
    return ext === '.pdf' || ext === '.epub';
  });

  console.log(`Found ${bookFiles.length} books in ${BOOKS_DIR}\n`);

  const insert = db.prepare(`
    INSERT OR IGNORE INTO books (title, author, format, filename, filepath)
    VALUES (?, ?, ?, ?, ?)
  `);

  let added = 0;

What does this section do?

This reads every file in your books directory, filters down to only .pdf and .epub files, then prepares a SQL insert statement. The INSERT OR IGNORE clause skips any book whose filename already exists in the database, so re-running the scanner is safe.

  • Complete the scanBooks function by adding the processing loop and the final call directly below the let added = 0; line:
  for (const file of bookFiles) {
    const filepath = join(BOOKS_DIR, file);
    const ext = extname(file).toLowerCase().slice(1);
    let metadata;

    if (ext === 'pdf') {
      metadata = await extractPdfMetadata(filepath);
    } else {
      metadata = await extractEpubMetadata(filepath);
    }

    const title = metadata.title || basename(file, `.${ext}`);
    const author = metadata.author || 'Unknown';

    const result = insert.run(title, author, ext, file, filepath);
    if (result.changes > 0) {
      added++;
      console.log(`Added: "${title}" by ${author} [${ext.toUpperCase()}]`);
    }
  }

  console.log(`\nScan complete! Added ${added} new books to the library.`);

  const count = db.prepare('SELECT COUNT(*) as total FROM books').get();
  console.log(`Total books in library: ${count.total}`);
}

scanBooks();

What does the loop do?

For each book file, the loop calls the appropriate extraction function (PDF or EPUB), falls back to the filename if no metadata was found, and inserts a row. The result.changes > 0 check means it only logs books that were newly added, not duplicates.

The final scanBooks() call at the bottom kicks everything off when you run the file.

  • Save scanner.js and test that the scanner logic executes by running:
node scanner.js

You should see an error like ENOENT: no such file or directory because the BOOKS_DIR path doesn't exist on your machine yet. That's expected. It proves the scanner is reaching your directory-reading code and trying to work.

Seeing a different error?

  • If you see "SyntaxError: Unexpected token", check that the for loop is inside the scanBooks function (indented) and the closing } of the function appears before the scanBooks() call.
  • If you see "ReferenceError: scanBooks is not defined", make sure the scanBooks(); call is at the very bottom of the file, outside the function body.

Still stuck? Help me figure out why my scanner.js throws an unexpected error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved scanner.js and move on to the next substep where you'll point the scanner at your real books folder.

ⓧ I'd like to double check the full code

Scroll through your scanner.js and confirm these sections appear in order:

  • Five import lines at the top (node:sqlite, node:fs/promises, node:path, pdf-parse, epub-metadata).
  • The BOOKS_DIR constant, the db initialization, and the CREATE TABLE statement.
  • The extractPdfMetadata function.
  • The extractEpubMetadata function.
  • The scanBooks function containing the for loop.
  • The scanBooks(); call at the very bottom.
Point the scanner at your books and run it

The scanner is complete, but it's pointing at a placeholder path that doesn't exist on your machine. You need to tell it where your actual book files live.

  • In scanner.js, find this line near the top:
const BOOKS_DIR = 'C:/Users/YourName/Books';
  • Replace the path string with the full path to the folder where your PDF and EPUB files are stored, so the line looks like this:
const BOOKS_DIR = '[[BOOKSDIR="C:/Users/YourName/Books"]]';

Use forward slashes on Windows

Even on Windows, use forward slashes in the path (e.g. C:/Users/Alex/Documents/Books). Backslashes need escaping in JavaScript strings, which leads to confusing double-backslash bugs.

  • Save scanner.js.
  • Before you run the scanner, think about what you expect to see. How many books do you have in that folder? Do you expect all of them to have embedded metadata, or will some fall back to the filename?
  • Run the scanner:
node scanner.js

Watch the terminal as it processes each file. You should see output like this:

Found 348 books in C:/Users/YourName/Books

Added: "Clean Code" by Robert C. Martin [PDF]
Added: "The Pragmatic Programmer" by David Thomas [EPUB]
...

Scan complete! Added 348 new books to the library.
Total books in library: 348

Your numbers will differ based on how many books you have and what metadata is embedded in them.

Seeing ENOENT or 0 books found?

  • Double-check that the path in BOOKS_DIR exactly matches your folder. Open the folder in File Explorer and copy the path from the address bar.
  • Make sure the folder contains .pdf or .epub files directly (not nested in subfolders). The scanner reads only the top-level directory.
  • If you see "Found 0 books", check that your files actually end in .pdf or .epub (case-insensitive). Other formats like .mobi are not supported.

Need help? Help me debug why my book scanner isn't finding any files.

Your book collection is now queryable data in a database instead of a pile of files. Next up, you'll build a web server and dashboard that puts all of this metadata on screen.

Serve the Dashboard

Your book metadata is sitting in the database, but the only way to see it right now is through terminal output. To actually browse your collection, you need a web server that exposes the data and an HTML page that renders it visually.

In this step, you'll create an Express server with a books API, build a dark-themed HTML page, and write JavaScript that fetches every book and renders it as a card in a responsive grid.

In this step, get ready to:
  • Build an Express server that serves a books API endpoint.
  • Create the HTML page and dark-themed CSS for the dashboard layout.
  • Write JavaScript that fetches books and renders them as cards in a grid.
Create the Express server with a books API

Express is a lightweight web framework for Node.js that handles routing and serving files. Your server will do two things: serve the static HTML/CSS/JS files from the public/ folder, and expose an API endpoint that returns book data as JSON.

  • Open server.js in VS Code and add the following code:
import express from 'express';
import { DatabaseSync } from 'node:sqlite';

const app = express();
const db = new DatabaseSync('./library.db');

app.use(express.json());
app.use(express.static('public'));

app.get('/api/books', (req, res) => {
  const stmt = db.prepare('SELECT * FROM books ORDER BY title ASC');
  const books = stmt.all();
  res.json(books);
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Library dashboard running at http://localhost:${PORT}`);
});

What does this code do?

  • express.json() enables the server to parse JSON request bodies (you'll need this later for toggling read status).
  • express.static('public') tells Express to serve any file inside the public/ folder when a browser requests it.
  • The GET /api/books route queries every book from the database, sorts them alphabetically by title, and returns the full list as JSON.
  • Save server.js.
  • Start the server by running this command in your terminal:
node server.js

You should see this message printed in the terminal:

Library dashboard running at http://localhost:3000
  • Open your browser and visit http://localhost:3000/api/books.

You should see a wall of JSON data containing all your books. Each entry has an id, title, author, format, filename, filepath, and is_read field.

Seeing an error instead of JSON?

  • If you see a "Cannot find module" error, make sure your package.json includes "type": "module" and that you ran npm install in the previous step.
  • If you see "SQLITE_ERROR: no such table: books", your library.db file is missing. Run node scanner.js again to create it.
  • help me debug my server.js error
Build the initial dashboard HTML and CSS

The API is working, but visiting the root URL shows a blank page. You need an HTML file for Express to serve when someone visits http://localhost:3000.

  • Open public/index.html and add the page structure:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Library Dashboard</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <h1>My Library</h1>
    <p id="book-count"></p>
  </header>
  <main id="book-grid"></main>
  <script src="app.js"></script>
</body>
</html>

What does this page do?

  • The <header> contains the page title and a paragraph element that will display the book count.
  • The <main id="book-grid"> is an empty container that JavaScript will fill with book cards.
  • The <script> tag at the bottom loads the JavaScript after the HTML is ready.
  • Save public/index.html.
  • Refresh http://localhost:3000 in your browser.

You should see "My Library" as a heading on a plain white page. The page works, but it looks bare. Time to add the dark theme.

  • Open public/style.css and add the page-level styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #1a1a2e;
  color: #e0e0e0;
  min-height: 100vh;
}

header {
  background: #16213e;
  padding: 2rem;
  border-bottom: 1px solid #0f3460;
}

h1 {
  font-size: 2rem;
  margin-bottom: 1rem;
  color: #e94560;
}

#book-count {
  color: #a0a0a0;
  font-size: 0.9rem;
}

What does this CSS do?

The reset removes browser default spacing. The body gets a deep navy background with light gray text. The header uses a slightly lighter navy with a subtle border to separate it from the content area below.

  • Save public/style.css and refresh your browser.

The page now has a dark navy background. The header shows "My Library" in a red-pink accent color against a darker panel. The foundation is in place.

Still seeing a white page?

  • Check that you saved the file as style.css inside the public/ folder, not in the project root.
  • Confirm your index.html has <link rel="stylesheet" href="style.css"> in the <head>.
  • help me figure out why my CSS isn't loading
Write the frontend JavaScript to fetch and render books

The page structure and header look good, but the grid is empty. You need JavaScript to fetch the book data from your API and render each book as a card. Before writing that JavaScript, add the card grid and container styles that the rendered cards will use.

  • In public/style.css, add the grid layout and card container styles below your existing code:
main {
  padding: 2rem;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

.book-card {
  background: #16213e;
  border: 1px solid #0f3460;
  border-radius: 12px;
  padding: 1.5rem;
  transition: transform 0.2s, border-color 0.2s;
}

.book-card:hover {
  transform: translateY(-2px);
  border-color: #e94560;
}

.book-title {
  font-size: 1.1rem;
  font-weight: 600;
  margin-bottom: 0.5rem;
  color: #ffffff;
}

What do these styles do?

The main element becomes a CSS grid that automatically fills rows with cards at least 280px wide. Each .book-card gets a dark panel look with rounded corners. On hover, cards lift up slightly and get an accent border.

  • Save public/style.css.
  • Now open public/app.js and add the JavaScript that fetches and renders your books:
// Grab references to the HTML elements we'll update
const bookGrid = document.getElementById('book-grid');
const bookCount = document.getElementById('book-count');

// Fetch all books from the API and render them
async function fetchBooks() {
  const response = await fetch('/api/books');
  const books = await response.json();
  renderBooks(books);
}

// Build a card for each book and insert into the grid
function renderBooks(books) {
  bookCount.textContent = `Showing ${books.length} book${books.length !== 1 ? 's' : ''}`;

  bookGrid.innerHTML = books.map(book => `
    <div class="book-card">
      <div class="book-title">${escapeHtml(book.title)}</div>
      <div class="book-author">${escapeHtml(book.author)}</div>
      <div class="book-meta">
        <span class="book-format">${book.format}</span>
      </div>
    </div>
  `).join('');
}

// Prevent XSS by escaping user-provided text
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

fetchBooks();

What does this JavaScript do?

  • fetchBooks() calls your Express API and passes the response to the render function.
  • renderBooks() generates an HTML card for each book using template literals, then injects them all into the grid at once.
  • escapeHtml() sanitizes book titles and authors so that special characters in metadata cannot be interpreted as HTML.
  • The final fetchBooks() call at the bottom runs immediately when the page loads.
  • Save public/app.js.
  • Refresh http://localhost:3000 in your browser.

You should see your entire book collection rendered as cards in a grid. Each card shows the book title in white, the author in gray, and a format badge. The count at the top should read "Showing 348 books" (or however many you scanned).

  • Now add the remaining card content styles. In public/style.css, add these styles below your existing code:
.book-author {
  color: #a0a0a0;
  margin-bottom: 0.75rem;
  font-size: 0.9rem;
}

.book-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.book-format {
  background: #0f3460;
  color: #e94560;
  padding: 0.25rem 0.75rem;
  border-radius: 20px;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
}

What do these styles do?

The author text gets a muted gray color. The .book-meta row uses flexbox to space the format badge and any future buttons apart. The format badge gets a pill shape with the accent color so PDF and EPUB stand out at a glance.

  • Save public/style.css and refresh your browser.

Before you look, what do you think changed? The format badges should now appear as small colored pills at the bottom of each card, and the author text should be smaller and lighter than the title.

Scroll through the grid. Try hovering over a card to see it lift and highlight. Every book in your collection is now visible in one place.

Cards not appearing or looking broken?

  • If the page is completely empty, open your browser's DevTools console (press F12) and check for JavaScript errors. A typo in app.js will prevent rendering.
  • If cards appear but look unstyled, make sure you added the CSS to public/style.css (inside the public/ folder) and saved the file.
  • If the count says "Showing 0 books", your server may not be finding library.db. Stop the server with Ctrl+C, confirm library.db is in the project root, and restart with node server.js.
  • help me fix my library dashboard card rendering

Notice the problem: with hundreds of books and no way to search or filter, finding anything specific means scrolling endlessly through the grid.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in your files, in this order:

  • server.js has the Express server with the /api/books route.
  • public/index.html has the header, book-count paragraph, and book-grid main element.
  • public/style.css has all three CSS chunks in sequence: page-level styles, grid and card container styles, and card content styles.
  • public/app.js has fetchBooks, renderBooks, escapeHtml, and the fetchBooks() call at the bottom.

Your entire collection is now visible in one place. Next up, you'll add search and filtering so you can actually find a specific book without scrolling through hundreds of cards.

Add Search and Filtering

Your dashboard now shows every book in your collection as a card. That's real progress.

But try finding a specific title in a wall of 348 cards. Scrolling through everything defeats the purpose of a dashboard. In this step, you'll add a search box and format dropdown that filter the grid in real time.

In this step, get ready to:
  • Add search and format filter controls to the HTML and style them.
  • Update the server to filter database results by search term and format.
  • Wire up the frontend JavaScript so filters apply as you type.
Add filter controls to the HTML

The search box and format dropdown need to live in the HTML before any JavaScript can reference them.

  • In public/index.html, find the <h1>My Library</h1> line.
  • Add the following controls div between the <h1> and the <p id="book-count"> tag:
    <div class="controls">
      <input type="text" id="search" placeholder="Search by title or author...">
      <select id="format-filter">
        <option value="all">All Formats</option>
        <option value="pdf">PDF</option>
        <option value="epub">EPUB</option>
      </select>
    </div>

What does this code do?

  • The controls div groups the filter inputs together for styling.
  • The text input lets the learner type a search query. Its id="search" gives JavaScript a handle to grab it.
  • The <select> dropdown offers three choices: all formats, PDF only, or EPUB only.

The controls need styling to match the dark theme. Without CSS, they'll appear as unstyled browser defaults.

  • In public/style.css, find the closing } of the h1 rule.
  • Add the following styles directly below it (before the #book-count rule):
.controls {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  margin-bottom: 1rem;
}

#search {
  flex: 1;
  min-width: 200px;
  padding: 0.75rem 1rem;
  border: 1px solid #0f3460;
  border-radius: 8px;
  background: #1a1a2e;
  color: #e0e0e0;
  font-size: 1rem;
}

#search:focus {
  outline: none;
  border-color: #e94560;
}

select {
  padding: 0.75rem 1rem;
  border: 1px solid #0f3460;
  border-radius: 8px;
  background: #1a1a2e;
  color: #e0e0e0;
  font-size: 1rem;
  cursor: pointer;
}

What does this code do?

  • The .controls class uses flexbox so the search input and dropdown sit side by side.
  • The search input stretches to fill available space with flex: 1, while the dropdown keeps its natural width.
  • Both elements use the same dark background and border color as the rest of the page.
  • Save both public/index.html and public/style.css.
  • Refresh your browser at http://localhost:3000.

You should see a search box and an "All Formats" dropdown appear between the title and the book count. They don't filter anything yet, but they're visible and styled.

Don't see the controls?

  • Make sure the <div class="controls"> block is inside the <header> element, between <h1> and <p id="book-count">.
  • Check that you saved both files. Unsaved CSS changes won't appear on refresh.

Why aren't my filter controls showing up?

Update the server to filter by query parameters

The controls are visible, but the server still returns every book regardless. You need the API to read query parameters and narrow the SQL query accordingly.

  • In server.js, find the existing route handler:
app.get('/api/books', (req, res) => {
  const stmt = db.prepare('SELECT * FROM books ORDER BY title ASC');
  const books = stmt.all();
  res.json(books);
});
  • Replace that entire route handler with this updated version:
app.get('/api/books', (req, res) => {
  const { search, format } = req.query;
  let sql = 'SELECT * FROM books WHERE 1=1';
  const params = [];

  if (search) {
    sql += ' AND (title LIKE ? OR author LIKE ?)';
    params.push(`%${search}%`, `%${search}%`);
  }

  if (format && format !== 'all') {
    sql += ' AND format = ?';
    params.push(format);
  }

  sql += ' ORDER BY title ASC';

  const stmt = db.prepare(sql);
  const books = stmt.all(...params);
  res.json(books);
});

What does this code do?

  • The route reads search and format from the URL query string (e.g. /api/books?search=clean&format=pdf).
  • The WHERE 1=1 trick lets you append AND clauses conditionally without worrying about whether it's the first condition.
  • The LIKE operator with % wildcards matches partial strings in both title and author columns.
  • Parameterized queries (the ? placeholders) prevent SQL injection.
  • Save server.js.
  • Stop the running server by pressing Ctrl+C in your terminal.
  • Restart it by running:
node server.js
  • Test the filtering by visiting this URL in your browser:
http://localhost:3000/api/books?search=clean

You should see a JSON array containing only books whose title or author includes "clean." If you see all books, the server didn't pick up your changes.

Still seeing all books in the JSON?

  • Make sure you saved server.js and restarted the server with Ctrl+C followed by node server.js. The old process won't pick up file changes.
  • Check that you replaced the entire old route handler, not just part of it.

Why is my API still returning all books when I add a search parameter?

Wire up the frontend search and filter logic

The server can filter, but the UI controls aren't connected to it yet. Typing in the search box does nothing because app.js still fetches /api/books with no parameters.

  • In public/app.js, find these two lines at the top of the file:
const bookGrid = document.getElementById('book-grid');
const bookCount = document.getElementById('book-count');
  • Replace everything from those two lines down to (but not including) the function renderBooks line with the following:
const searchInput = document.getElementById('search');
const formatFilter = document.getElementById('format-filter');
const bookGrid = document.getElementById('book-grid');
const bookCount = document.getElementById('book-count');

let debounceTimer;

async function fetchBooks() {
  // Read current values from the filter controls
  const search = searchInput.value;
  const format = formatFilter.value;

  // Build query string from active filters
  const params = new URLSearchParams();
  if (search) params.set('search', search);
  if (format !== 'all') params.set('format', format);

  const response = await fetch(`/api/books?${params}`);
  const books = await response.json();
  renderBooks(books);
}

What does this code do?

  • Two new constants (searchInput and formatFilter) grab references to the HTML elements you added earlier.
  • The debounceTimer variable will hold a timeout ID so the app can delay requests while the user is still typing.
  • The updated fetchBooks reads the current filter values, builds a URLSearchParams query string, and appends it to the API URL.
  • Now scroll to the bottom of public/app.js and find the standalone fetchBooks(); line (the last line in the file).
  • Replace that single line with the following event listeners and initial call:
// Debounce search: wait 300ms after typing stops before fetching
searchInput.addEventListener('input', () => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(fetchBooks, 300);
});

// Format dropdown triggers an immediate refetch
formatFilter.addEventListener('change', fetchBooks);

fetchBooks();

What is debouncing?

Without debouncing, the app would send a network request on every single keystroke. Typing "Robert" would fire 6 requests in rapid succession, most of them wasted.

The debounce waits 300 milliseconds after the user stops typing before sending the request. If they type another character within that window, the timer resets. This means only one request fires for the complete word.

Before you test: what do you think will happen when you type a partial author name into the search box?

  • Save public/app.js.
  • Refresh your browser at http://localhost:3000.
  • Type an author name into the search box.

The grid narrows to show only books matching that name. Clear the search and select "EPUB" from the format dropdown. Only EPUB files appear.

  • Select "PDF" from the dropdown and confirm only PDFs show.
  • Switch back to "All Formats" and confirm all books return.

Search not filtering?

  • Open the browser console with F12 and check for errors. The most common issue is a typo in document.getElementById('search'). The string must match the id="search" attribute in your HTML exactly.
  • If the dropdown works but search doesn't, verify the input event listener is attached to searchInput, not search.

My search input isn't filtering books when I type. How do I debug this?

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files: index.html, style.css, server.js, and app.js.

ⓧ I'd like to double check the full code

Here is public/index.html in full:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Library Dashboard</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <h1>My Library</h1>
    <div class="controls">
      <input type="text" id="search" placeholder="Search by title or author...">
      <select id="format-filter">
        <option value="all">All Formats</option>
        <option value="pdf">PDF</option>
        <option value="epub">EPUB</option>
      </select>
    </div>
    <p id="book-count"></p>
  </header>
  <main id="book-grid"></main>
  <script src="app.js"></script>
</body>
</html>

Here is public/app.js. Confirm the top declarations, the updated fetchBooks function, and the event listeners at the bottom all match:

const searchInput = document.getElementById('search');
const formatFilter = document.getElementById('format-filter');
const bookGrid = document.getElementById('book-grid');
const bookCount = document.getElementById('book-count');

let debounceTimer;

async function fetchBooks() {
  const search = searchInput.value;
  const format = formatFilter.value;

  const params = new URLSearchParams();
  if (search) params.set('search', search);
  if (format !== 'all') params.set('format', format);

  const response = await fetch(`/api/books?${params}`);
  const books = await response.json();
  renderBooks(books);
}
function renderBooks(books) {
  bookCount.textContent = `Showing ${books.length} book${books.length !== 1 ? 's' : ''}`;

  bookGrid.innerHTML = books.map(book => `
    <div class="book-card">
      <div class="book-title">${escapeHtml(book.title)}</div>
      <div class="book-author">${escapeHtml(book.author)}</div>
      <div class="book-meta">
        <span class="book-format">${book.format}</span>
      </div>
    </div>
  `).join('');
}

function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}
searchInput.addEventListener('input', () => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(fetchBooks, 300);
});

formatFilter.addEventListener('change', fetchBooks);

fetchBooks();

For server.js, confirm the route handler matches the one you pasted earlier (with search and format query parameter handling). For style.css, confirm the .controls, #search, #search:focus, and select rules are present between the h1 and #book-count rules.

Your dashboard just went from a gallery you scroll through to a tool you search. Next up, you'll add read/unread tracking so you can see which books you've actually finished.

Track Read Status

Your dashboard can now search and filter hundreds of books instantly. You have gone from a wall of cards to a focused, navigable collection.

Knowing what you own is only half the battle. Tracking what you have actually read turns the dashboard from a static catalog into a reading progress tracker that motivates you to work through the collection.

In this step, get ready to:
  • Add a PATCH endpoint and status filter to the server.
  • Add a read/unread toggle button to each book card.
  • Persist read status across page refreshes.
Add the PATCH endpoint and status filter

The database already has an is_read column on every book (defaulting to 0). You need a way to update that column and a way to filter by it.

  • In server.js, find this line inside the GET route:
  const { search, format } = req.query;
  • Replace it with this line to also extract the status parameter:
  const { search, format, status } = req.query;
  • Now find the format filtering block (the if (format && format !== 'all') section). Add the following status filtering code directly below it:
  // Filter by read status if specified
  if (status === 'read') {
    sql += ' AND is_read = 1';
  } else if (status === 'unread') {
    sql += ' AND is_read = 0';
  }

What does this code do?

This extends the existing SQL query with a conditional WHERE clause. When the frontend sends ?status=read, the API returns only books where is_read = 1. When it sends ?status=unread, only books with is_read = 0 come back.

  • Below the closing }); of the GET route, add the PATCH endpoint that lets the frontend toggle a book's read status:
app.patch('/api/books/:id', (req, res) => {
  const { id } = req.params;
  const { is_read } = req.body;
  // Update the is_read column for this specific book
  const stmt = db.prepare('UPDATE books SET is_read = ? WHERE id = ?');
  stmt.run(is_read, Number(id));
  res.json({ success: true });
});

What does this endpoint do?

A PATCH request to /api/books/42 with a JSON body like { "is_read": 1 } flips that book's status in the database. The frontend will call this every time you click a toggle button.

  • Save server.js.

Now add the status filter dropdown to the HTML so users can filter by read state.

  • In public/index.html, find the closing </select> tag of the format-filter dropdown. Add this new dropdown directly below it:
      <select id="status-filter">
        <option value="all">All Status</option>
        <option value="read">Read</option>
        <option value="unread">Unread</option>
      </select>

What does this add?

This gives users a third filter control alongside search and format. The value attributes match the status query parameter the server now expects.

  • Save public/index.html.
  • Switch to your terminal and stop the running server with Ctrl+C.
  • Restart the server by running:
node server.js
  • Refresh http://localhost:3000 in your browser.

You should see the new All Status dropdown next to the format filter. It does not filter yet because the frontend JavaScript has not been wired up. You can verify the server-side logic works by visiting http://localhost:3000/api/books?status=unread directly. You should see all your books returned as JSON (since none are marked as read yet).

Don't see the new dropdown?

  • Make sure you saved public/index.html before refreshing.
  • Check that the new <select> is inside the .controls div, not outside it.
  • Hard-refresh with Ctrl+Shift+R to clear any cached HTML.

Still stuck? Why isn't my status dropdown showing up in the browser?

Add the read toggle button to each card

The server can now update and filter by read status. The frontend needs to display a toggle button on each card and wire up the status dropdown.

  • In public/app.js, find this line:
const formatFilter = document.getElementById('format-filter');
  • Add this line directly below it to grab the new status dropdown:
const statusFilter = document.getElementById('status-filter');
  • Inside the fetchBooks function, find this line:
  const format = formatFilter.value;
  • Add this line directly below it:
  const status = statusFilter.value;
  • Still inside fetchBooks, find this line:
  if (format !== 'all') params.set('format', format);
  • Add this line directly below it:
  if (status !== 'all') params.set('status', status);

Now replace the entire renderBooks function with this updated version that adds a toggle button and a read-state class to each card:

  • Find your existing renderBooks function and replace it entirely with:
function renderBooks(books) {
  bookCount.textContent = `Showing ${books.length} book${books.length !== 1 ? 's' : ''}`;

  bookGrid.innerHTML = books.map(book => `
    <div class="book-card ${book.is_read ? 'read' : ''}">
      <div class="book-title">${escapeHtml(book.title)}</div>
      <div class="book-author">${escapeHtml(book.author)}</div>
      <div class="book-meta">
        <span class="book-format">${book.format}</span>
        <button class="read-toggle ${book.is_read ? 'is-read' : ''}"
                data-id="${book.id}"
                data-read="${book.is_read}">
          ${book.is_read ? 'Read' : 'Unread'}
        </button>
      </div>
    </div>
  `).join('');
}

What changed in renderBooks?

  • Each card now gets the read class when is_read is truthy, triggering the green border style.
  • A <button> element shows "Read" or "Unread" based on the current status.
  • The data-id and data-read attributes store the book's ID and current status so the click handler can access them.
  • Below the escapeHtml function, add the toggle logic and event delegation:
// Send a PATCH request to flip a book's read status
async function toggleRead(id, currentStatus) {
  const newStatus = currentStatus === 1 ? 0 : 1;
  await fetch(`/api/books/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ is_read: newStatus }),
  });
  fetchBooks();
}

// Listen for clicks on any toggle button using event delegation
bookGrid.addEventListener('click', (e) => {
  if (e.target.classList.contains('read-toggle')) {
    const id = e.target.dataset.id;
    const currentRead = parseInt(e.target.dataset.read);
    toggleRead(id, currentRead);
  }
});

Why event delegation?

Instead of attaching a click listener to each individual button (which would need re-attaching every time the grid re-renders), one listener on the parent bookGrid catches all button clicks. This pattern is efficient and works even when cards are re-rendered.

  • Finally, find the formatFilter.addEventListener('change', fetchBooks); line near the bottom of the file.
  • Add this line directly below it to wire up the status dropdown:
statusFilter.addEventListener('change', fetchBooks);
  • Save public/app.js.

Now add the styles that make the read state and toggle button visible.

  • In public/style.css, add the following at the end of the file:
.book-card.read {
  border-left: 4px solid #4caf50;
}

.read-toggle {
  background: none;
  border: 2px solid #4caf50;
  color: #4caf50;
  padding: 0.4rem 0.8rem;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.85rem;
  transition: all 0.2s;
}

.read-toggle:hover {
  background: #4caf50;
  color: #ffffff;
}

.read-toggle.is-read {
  background: #4caf50;
  color: #ffffff;
}

What do these styles do?

  • The .book-card.read rule adds a green left border to any card marked as read.
  • The .read-toggle styles create a green-outlined button that fills solid green when the book is read (.is-read class).
  • Save public/style.css.

Before you refresh, what do you think will happen when you click an "Unread" button on one of the book cards?

  • Refresh your browser at http://localhost:3000.

Every card now shows an "Unread" button. Click one. The button changes to "Read" with a solid green fill, and a green left border appears on the card. Refresh the page. The status persists because it was saved to the database.

  • Select Read from the status dropdown to confirm only your marked book appears.
  • Select Unread to see the rest of your collection.

Toggle button not working?

  • Open the browser DevTools console (F12) and check for errors. A 404 on the PATCH request means the server was not restarted after editing server.js.
  • Confirm the app.use(express.json()) line is present in server.js. Without it, the server cannot read the JSON body from the PATCH request.
  • Make sure data-id in the button template uses ${book.id} and not ${book.ID}. JavaScript is case-sensitive.

Need more help? My read toggle button doesn't change when I click it. How do I debug this?

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: server.js, public/index.html, public/app.js, and public/style.css.

ⓧ I'd like to double check the full code

Here is the full public/index.html at this point:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Library Dashboard</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <h1>My Library</h1>
    <div class="controls">
      <input type="text" id="search" placeholder="Search by title or author...">
      <select id="format-filter">
        <option value="all">All Formats</option>
        <option value="pdf">PDF</option>
        <option value="epub">EPUB</option>
      </select>
      <select id="status-filter">
        <option value="all">All Status</option>
        <option value="read">Read</option>
        <option value="unread">Unread</option>
      </select>
    </div>
    <p id="book-count"></p>
  </header>
  <main id="book-grid"></main>
  <script src="app.js"></script>
</body>
</html>

For the other files (which exceed 30 lines), scroll up and confirm each block you added is present in order:

  • server.js: the status destructuring, the status filtering block after the format block, and the PATCH route below the GET route.
  • public/app.js: the statusFilter variable, the status lines in fetchBooks, the updated renderBooks with toggle button, the toggleRead function, the bookGrid click listener, and the statusFilter change listener.
  • public/style.css: the .book-card.read, .read-toggle, .read-toggle:hover, and .read-toggle.is-read rules at the bottom of the file.

Your library dashboard now tracks what you have read. Every toggle click persists to the database, so your progress is never lost. Next up, you will take this further with a secret mission that adds a statistics panel showing your reading progress at a glance.

Secret mission

Add a Statistics Panel

Your dashboard shows every book, but it does not tell you anything about your reading habits at a glance. Add a statistics panel that aggregates your collection into totals, read/unread counts, and a format breakdown.

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:

  • library-dashboard folder on your Desktop (all project code, config, and dependencies).
  • library.db SQLite database file inside the project folder (stores your entire book catalog and read status).

Keep everything running

No action needed. Choose this if you are still actively building or using your library dashboard.

  • Leave node server.js running in your terminal to access your dashboard anytime at http://localhost:3000.
  • Your book data persists in library.db even if you restart the server.
  • Your original book files are never modified by this project.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your data stays safe in the database file.

  • In the terminal where the server is running, press Ctrl+C to stop the server.
  • When you want to use the dashboard again, open a terminal in the library-dashboard folder and run node server.js.
  • All your books and read statuses persist in library.db between sessions.

Delete - I don't want to use this again

Remove all project files and start fresh. Your original book files are completely untouched since the scanner only reads them.

  • In the terminal where the server is running, press Ctrl+C to stop the server.
  • Navigate up one level out of the project folder by running:
cd ~/Desktop
  • Delete the entire project folder by running:

macOS

rm -rf library-dashboard

Windows

rmdir /s /q library-dashboard
  • Confirm the folder is gone by running:
ls

You should no longer see library-dashboard in the output. Your book files remain exactly where they were.

Nice Work!

Nice Work!

You just built a fully functional personal library dashboard from scratch. Your book collection is now searchable, filterable, and trackable from any browser on your machine.

You've learned how to:

  • Build a file scanner that cracks open PDF and EPUB files to extract embedded title and author metadata using pdf-parse and epub-metadata, storing everything in a SQLite database powered by Node.js 24's built-in node:sqlite module.
  • Create a REST API with Express that serves book data with search, format filtering, and read-status filtering through query parameters.
  • Build a responsive dark-themed dashboard with real-time debounced search, format and status dropdowns, and a one-click read/unread toggle that persists across sessions.
  • Secret Mission: Added a statistics panel that aggregates reading progress and format distribution, updating live after every toggle.

Ready to quiz yourself?