Search

Personal Catalogue: React, Express & MongoDB

Chapter 6 · Search

Chapter 5 answered "which books have this tag?" This chapter answers Chapter 1's own original, broader question — "do I already own this?" — for any item, matched by title or by whichever identifying field that item's own type actually has: author for a Book, artist for a Cd, director for a Dvd or Bluray. Building the query is the easy part. Getting it wired in correctly, without two real, verified crashes along the way, is what this chapter is actually about.

A Search Query Spanning Fields That Don't All Exist on Every Type

No single field means "creator" across all four item types — that's exactly the real distinction Chapter 1's own compare-table drew against the relational siblings' generic creator column. A search that respects it has to check several genuinely different field names at once, with $or:

// A first attempt — builds the right query, but ships with two real bugs, both found below router.get('/search', async (req, res) => { const q = req.query.q; const regex = new RegExp(q, 'i'); const items = await Item.find({ $or: [{ title: regex }, { author: regex }, { artist: regex }, { director: regex }], }).lean(); res.json(items); });

Why Querying by author Even Works on the Base Item Model

author and artist aren't paths on Item's own base schema — only Book and Cd know about them respectively. A query built directly against Item that references them isn't automatically safe just because MongoDB itself is schemaless; Mongoose sits between the two, and how it treats an unrecognized query field depends on one specific, version-dependent setting.

Verified Against Mongoose's Own Documentation
The setting is strictQuery. Under strictQuery: true — the real default in Mongoose 6 — a query field that isn't part of the model's own schema is silently stripped out before the query ever reaches MongoDB, which would have quietly turned this route's own $or conditions on author/artist/director into no real filter at all. Mongoose 7 changed the default to strictQuery: false, under which an unrecognized field is passed through to MongoDB completely unchanged — confirmed directly by searching a term that only appears in a real Book's own author field and getting that Book back. A plain npm install mongoose today installs a version well past that change, so this route works correctly with no extra configuration — but it's worth knowing the setting exists, since an older project, or one that explicitly sets strictQuery: true for other reasons, would see this exact query silently return far more (or far less) than intended.

A Route-Ordering Bug: /search Swallowed by /:id

The route above looks correct in isolation. Add it to routes/items.js at the bottom of the file — after Chapter 3's own GET /:id — and it never actually runs:

// routes/items.js — registration order that reproduces the bug router.get('/', ...); // Chapter 3/5 router.get('/:id', ...); // Chapter 3 router.put('/:id', ...); // Chapter 3 router.delete('/:id', ...); // Chapter 3 router.patch('/:id/tags/add', ...); // Chapter 5 router.patch('/:id/tags/remove', ...); // Chapter 5 router.get('/search', ...); // added last — this is the bug

Express matches routes in the exact order they're registered, and both /:id and /search are single path segments beneath /api/items. A request to GET /api/items/search?q=kurosawa matches /:id first — since it was registered earlier — with req.params.id literally set to the string "search". Item.findById("search") then runs, and MongoDB ObjectIds aren't arbitrary strings: Mongoose throws a real CastError ("Cast to ObjectId failed for value 'search'") before the query ever reaches the database.

That error is thrown inside an async handler, so Express 5 forwards it automatically — no manual next(err) needed. But with no custom error-handling middleware registered anywhere yet, it falls through to Express's own built-in default handler, which responds with a generic HTML error page and a 500 status — not the clean { error: '...' } JSON shape every other route in this API returns.

The Fix Is Purely About Order
Nothing about either route's own logic is wrong. Moving router.get('/search', ...) to sit before router.get('/:id', ...) in the file is the entire fix — Express then checks for a literal /search match first, and only falls through to the :id wildcard for anything that isn't that exact path.

A Second Real Crash: Unescaped Regex Input

With the ordering fixed, a second problem surfaces on its own. Searching for a genuinely ordinary title — something like "Se7en" is fine, but a search for "Mission: Impossible (" (an unmatched opening parenthesis, easy to type by accident) hits new RegExp(q, 'i') directly with q still holding that raw, untouched character.

An unmatched ( isn't valid regex syntax — it opens a capturing group with no matching ) to close it. new RegExp() throws a real, synchronous SyntaxError: Invalid regular expression the instant it's constructed, before the query even reaches MongoDB. The result is the exact same symptom as the ordering bug — a generic HTML 500 page, not JSON — for a genuinely different underlying reason.

The fix treats whatever the user typed as literal text to search for, not as regex syntax to interpret, using the standard MDN-documented escape pattern for regex metacharacters:

// routes/items.js — a small helper, defined once near the top of the file function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

Escaping Mission: Impossible ( turns the lone ( into a literal \( before it ever reaches new RegExp() — the constructor sees valid syntax, and the resulting regex looks for that exact parenthesis character rather than trying to open a group with it. The same fix also handles the more everyday version of the same problem: a real title like "C++" or "Se7en" containing characters that mean something different in regex than they do in a plain title.

A Real Safety Net: Global JSON Error-Handling Middleware

Both bugs above happened to produce the identical symptom for a real, shared reason: nothing in this project has ever told Express what an uncaught error should look like to a client. Fixing the two specific causes is necessary, but a small addition in server.js closes the actual gap, for these two cases and every one this project hasn't hit yet:

// server.js — registered last, after every route is mounted app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: 'Server error' }); });
Four Parameters Is What Makes This an Error Handler
Express identifies error-handling middleware specifically by its arity — a function with exactly four parameters, (err, req, res, next). Registering it after every other app.use() and route means it only runs once something upstream has actually thrown or rejected, exactly the case Express 5's own automatic async-error forwarding routes into it with no extra code required at each individual route.

The Finished Search Route

// routes/items.js — registered before GET /:id, with escaped input function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } router.get('/search', async (req, res) => { const q = (req.query.q || '').trim(); if (!q) { return res.status(400).json({ error: 'q is required' }); } const regex = new RegExp(escapeRegex(q), 'i'); const items = await Item.find({ $or: [{ title: regex }, { author: regex }, { artist: regex }, { director: regex }], }).lean(); res.json(items); }); // GET /:id (Chapter 3) is registered after this point, not before
A Full Collection Scan, Deliberately
A case-insensitive regex generally can't use a standard MongoDB index, so this route scans every document in the collection on every search. That's genuinely fine at the scale a personal catalogue actually reaches — a few hundred to a few thousand items — and not a decision this project needs to revisit unless the collection grows dramatically larger, at which point a real text index or an external search service would replace this approach entirely.

A Small Search Bar Component

Parallel to Chapter 5's own TagFilter, scoped to the request/response cycle rather than rendering results — Chapter 7 builds the real display:

// src/components/SearchBar.jsx import { useState } from 'react'; export default function SearchBar({ onResults }) { const [q, setQ] = useState(''); async function search() { const url = q ? `http://localhost:4000/api/items/search?q=${encodeURIComponent(q)}` : 'http://localhost:4000/api/items'; const items = await (await fetch(url)).json(); onResults(items); } return ( <div> <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by title, author, artist, or director" /> <button onClick={search}>Search</button> </div> ); }

Trying It End to End

# Matches a Book by title curl "http://localhost:4000/api/items/search?q=Clean+Code" # Matches the same Book by author, even though the term never appears in the title curl "http://localhost:4000/api/items/search?q=Robert+Martin" # Matches a Dvd by director curl "http://localhost:4000/api/items/search?q=Kurosawa" # A genuinely awkward title, now handled correctly instead of crashing curl "http://localhost:4000/api/items/search?q=Mission%3A%20Impossible%20(" # A term matching nothing returns a clean empty array, not an error curl "http://localhost:4000/api/items/search?q=Nonexistent"

Hands-On Exercises

Exercise 1

Build the finished /search route (correctly ordered, with escaped input) plus the global error-handling middleware, then confirm a search for an author's name returns the correct Book even when that name never appears in the title, and a search for a nonexistent term returns an empty array rather than an error.

📄 View solution
Exercise 2

Temporarily move the /search route to after GET /:id, reproducing the route-ordering bug — confirm a real CastError-triggered response for a search request. Restore the correct order and confirm the fix.

📄 View solution
Exercise 3

Temporarily remove escapeRegex() from the search route and search for a term containing an unmatched parenthesis, reproducing the SyntaxError crash. Restore escaping and confirm the identical search term now returns a normal response instead.

📄 View solution

Chapter 6 Quick Reference

  • $or across differing field names — title/author/artist/director, matching whichever field a given item type actually has
  • strictQuery — Mongoose 7+'s default (false) lets unknown-to-Item's-schema fields query correctly; Mongoose 6's old default (true) would have silently stripped them
  • Route ordering — a single-segment route like /search must be registered before a wildcard-like /:id route, or the latter shadows it
  • Regex escaping — user search input is escaped before becoming a RegExp, so special characters are matched literally instead of crashing the route or changing what's matched
  • Global error middleware — a 4-parameter (err, req, res, next) handler, registered last, turning any future uncaught error into clean JSON instead of Express's default HTML page
  • Scale note — a full collection scan on every search, a deliberate and reasonable choice at personal-catalogue scale
  • Next chapter: The Catalogue List & Detail Views — the real React UI these search and tag results have been waiting for