How I Built a Self-Updating Search Bar Using Claude Code
From architecture decision to React portal — why a simple search bar required a codebase refactor, a build script, and fixing a CSS rule I didn’t know existed
Ingredients
- Claude Code — terminal-based AI for direct file editing ($200/yr)
- Next.js — the React framework running the site (free)
- tsx — a TypeScript runner that executes scripts without a separate compilation step (free)
- 14 pages worth of content — the site’s pages, writing posts, and features that needed to be searchable
The Problem: The Site Was Getting Too Big to Navigate by Eye
When the site launched, there were four pages. By March, there were fourteen — seven writing posts, two interactive features, five static pages. Someone looking for the post about Garmin automations had to scroll through the Writing index and scan titles. Someone looking for Fruit Exchange had to know it existed to find it.
The site needed search. The question wasn’t really "should we add search" — it was "how do we build it in a way that doesn’t fall apart as the site keeps growing?" That’s where the real design work started.
Session 1: The Architecture Decision
Pace: Slow start (making the right structural decision up front), fast execution after.
Before writing any code, Claude and I worked through how the search index should be structured. A search index is essentially a master list of everything on the site that search can look through — every page title, every post subtitle, every feature description — stored in a single file.
The first instinct was the simplest option: a hand-maintained JSON file. Create it once, update it when you add content. But I knew this would break down fast. The Writing section alone was growing every week. Forget to update the file after adding a post and the post simply wouldn’t appear in search results — silently, with no warning.
The better option was a build script — a small program that runs automatically every time the site gets built for production, reading the actual list of posts and writing the search index file on the fly. The tradeoff: it required a code refactor first.
If you’re building something that you’ll maintain for years, the ten-minute upfront investment in automation pays for itself the first time you forget to do the manual version. The static file was simpler to describe. The build script was simpler to live with.
The Refactor: One Source of Truth
The problem: the list of Writing posts was defined inside the Writing page file itself — the code that displayed the posts and the list of posts were in the same place. That works fine for a single page, but a build script in a separate file couldn’t read it.
The fix was a refactor — moving the posts list into its own file (app/lib/posts.ts) that both the Writing page and the build script could import independently. Think of it like moving a recipe from inside a cooking show script into a recipe card that any show can reference. The Writing page still works exactly the same; now the build script can also see the same list.
🔧 Developer section: Refactor and build script
- Created
app/lib/posts.tswith a typedPostarray — moved all 7 post objects (slug, title, subtitle, date, readTime) out ofapp/writing/page.tsx - Updated
app/writing/page.tsxtoimport { posts } from "@/app/lib/posts"— one-line change, zero visual difference - Created
scripts/generate-search-index.ts— imports posts from the shared file, combines them with 7 hardcoded static page entries (Home, About, Work, Writing, Contact, Numerator, Fruit Exchange), writes the result topublic/search-index.json - Installed
tsxas a dev dependency — a TypeScript script runner that lets Node execute.tsfiles directly without a separate compile step - Added
"prebuild": "tsx scripts/generate-search-index.ts"topackage.json— this runs automatically before every production build, so the index is always current - Added
"generate:search"as a standalone script for manual runs — ran it once to create the initialsearch-index.json
One command generates a fresh index. Every production build runs this automatically — no manual updates needed.
From now on, adding a new post means one thing: add it to app/lib/posts.ts. The Writing page picks it up. The search index picks it up. Nothing else to remember.
Session 2: Building the Search Component
Pace: Fast. The index structure was already decided; this was pure UI execution.
With the index in place, Claude built the search component — the visible piece that visitors interact with. The design: a magnifying glass icon in the navigation bar. Click it, and a panel drops down with a search input. Start typing and results appear instantly, filtered from the index, with the matching word highlighted in yellow.
🔧 Developer section: SearchBar component
- Lazy loading: the search index JSON is fetched from the server only once, on first open — not on page load. Cached in component state for the rest of the session.
- Debounced input: a 200ms delay before filtering results, so the search doesn’t recalculate on every single keystroke while you’re still typing
- Keyboard navigation: arrow keys move the active result up and down; Enter navigates to the selected page; Escape closes the panel
- Text highlighting: the matching portion of each result title and description is wrapped in a
<mark>tag styled in the site’s signature yellow - Type badge: each result shows a small label — Page, Writing, or Feature — so you know what kind of content you’re looking at before clicking
- Click-outside to close: a
mousedownlistener on the document closes the panel when you click anywhere outside it - Controlled state: the open/close state is lifted into the Nav component so the mobile menu can also trigger the same search overlay without duplicating logic
The panel drops down right-aligned under the nav, positioned to line up with the right edge of the navigation bar — same max-width and padding as the nav itself, using justify-content: flex-end to push it to that edge.
Session 3: The Mobile Bug
Pace: Confusing at first, then a clean fix once the root cause was found.
The search worked perfectly on desktop. On mobile, the panel opened — but you couldn’t type anything into the input. The letters just wouldn’t appear.
This took a moment to diagnose. The SearchBar component lived inside the site’s navigation bar — specifically inside the list of nav links. On mobile, those nav links are hidden with a CSS rule: display: none. The search icon, being inside that hidden list, was also hidden — that was expected. But the search overlay panel was hidden too, even though it used position: fixed which normally takes an element out of the page flow entirely.
It turns out display: none is absolute. When a parent element is set to display none, every single one of its children is hidden — no exceptions, regardless of whether the child is fixed, absolute, or anything else. The overlay panel existed in the DOM (React had rendered it), but the browser was refusing to paint it because its great-grandparent was invisible.
position: fixed breaks free from the normal document layout — but not from display: none. A fixed element inside a hidden parent is still hidden. If you need an overlay that’s always visible regardless of where it lives in your component tree, it needs to render outside that tree entirely.
The fix was a React Portal. A portal is a way of rendering a component at a completely different location in the page’s HTML structure — in this case, directly on the <body> tag, outside the navigation entirely. The component still belongs to the nav (React tracks it as part of the same tree for state and events), but it renders in a location where no parent can hide it.
🔧 Developer section: Portal implementation
- Imported
createPortalfromreact-dom— this is built into React, no new package needed - Added a
mountedstate flag that only becomestrueafter the component loads in the browser — portals requiredocument.bodyto exist, which isn’t available during server-side rendering - Wrapped the overlay JSX in
createPortal(overlay, document.body)— the overlay now renders as a direct child of<body>in the HTML output, completely outside the nav’sdisplay: nonereach - Updated the overlay’s
topposition from a hardcoded73pxto a dynamic measurement — on open, the component measures the actual header height withgetBoundingClientRect()so the overlay always sits flush below the nav regardless of future layout changes
Session 4: Nav Redesign
Pace: Fast visual iteration once the layout direction was decided.
Adding search also revealed a crowding problem. The navigation bar had been accumulating items over time — About, Work, Writing, Fruit Exchange, Contact, a search icon, and a yellow Play Numerator button. A new green Fruit Exchange button was added the same session. Seven items plus two distinct CTA buttons in a single row was too much.
The solution: split the nav into two rows. The first row keeps the text links and search icon. The second row holds only the CTA buttons, right-aligned, with room to add more features in the future without crowding the primary navigation.
🔧 Developer section: Two-row nav layout
- Added a new
.nav-cta-rowdiv below the existing.nav-wrapinside the<header>— samemax-widthand horizontal padding as the nav,justify-content: flex-endto right-align the buttons - Moved both CTA buttons (Play Numerator and Fruit Exchange) out of the nav links list and into the new row, with a tight
10pxgap between them - Changed
.nav-wrapfrom a fixedheight: 72pxto explicit padding (20px 48px 10px) — this reduces the empty space between the first row and the second row by half, so the buttons feel connected to the nav rather than floating below it - Added
display: noneat the 960px mobile breakpoint for.nav-cta-row— on mobile, both CTA buttons live in the hamburger dropdown instead - Fruit Exchange was also given a green button style matching
#264635— same treatment as Numerator’s yellow, signaling it as an interactive feature
Final Output
A site-wide search bar at joseandgoose.com — click the magnifying glass in the nav — with a self-regenerating 14-entry search index (7 posts, 5 static pages, 2 features), debounced input, keyboard navigation, yellow text highlighting, type badges, mobile support via React portal, and a redesigned two-row navigation that has room to grow. Adding a new post now requires one step: add it to app/lib/posts.ts.
What went fast
- The build script — once the refactor was done, the generator was ~50 lines and ran first try. The terminal printed “✓ search-index.json written (14 entries)” immediately.
- The SearchBar component — keyboard navigation, debounce, highlighting, and click-outside all built in one shot. Zero rework on the logic.
- The portal fix — once the root cause was clear (
display: nonehiding fixed children), the fix was three lines. Clean, no side effects. - The two-row nav layout — a straightforward CSS restructure. Moving elements into a new container and adjusting padding took under 15 minutes.
What needed patience
- Diagnosing the mobile bug — the symptom (can’t type) didn’t point obviously to the cause (
display: noneon a grandparent element). It took a few minutes of reading the CSS before the connection clicked. - Nav spacing — multiple rounds of feedback to get the vertical gap between the two nav rows to feel tight but not cramped. The final fix was switching from a fixed pixel height to explicit top/bottom padding, which let me control each side independently.
- Mobile Fruit Exchange button CSS — the generic mobile menu link selector was more specific than the button class, requiring a higher-specificity selector to override the color. A small thing that was invisible until tested on mobile.
The decision that made everything else easier
The five minutes spent deciding not to use a static JSON file changed the entire build. It added a refactor (moving the posts array) and a script (the generator), but it removed the permanent maintenance cost of keeping a file in sync by hand. Every session after that — the search component, the portal fix, the nav redesign — was cleaner because the data architecture was right from the start.
When you’re building on a site you plan to keep adding to, the question isn’t just “what works now?” — it’s “what will I still trust in six months?” The build script is what I’ll still trust.