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:
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.
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:
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.
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:
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:
(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
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:
Trying It End to End
Hands-On Exercises
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 solutionTemporarily 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 solutionTemporarily 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 solutionChapter 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