ABS Search Bar: The Cheap State Update, Not a Popup
A single client-side script, four pages, no backend. The ABS search bar filters a JSON index already on the page — sub-50ms on every keystroke, no popup.

The rule
The ABS search bar is a plain
<input type="search">at the top of every archive page. It filters a JSON index already on the page. No modal, no overlay, no popup, no second screen, no API call. The DOM updates inline, and the URL does not change.
Why a state update, not a popup
The alternative we considered was a search-results modal — typed, hit enter, a panel slid in from the top. It is the pattern almost every content site uses. We rejected it for three reasons:
- It hides the page. When the modal opens, the user loses their scroll position on the archive list. On
/news/1/with 30 items, that scroll is half the user’s context — where they were in the feed. - It requires focus-management. A modal needs trap focus, ESC-to-close, click-outside-to-dismiss. Every one of those is a thing to test. The inline pattern needs none of them.
- It costs an API round-trip. Every keystroke (or every debounced keystroke) is a fetch. Even on localhost, that is a network tab entry and a load-balancer log line. The inline pattern costs zero.
The trade-off: the inline results take vertical space, and a long query result can push the first item down. Acceptable on a 30-item page; not acceptable on a 5,000-item page. ABS’s largest archive is /news/ at ~157 items. Plenty of room.
What ships today
| Page | Has search bar? | Index source |
|---|---|---|
/ (homepage) | yes | full collection — every news + review + guide |
/news/ (and /news/1/–/news/6/) | yes | news only (157 items) |
/reviews/ | yes | reviews only (32 items) |
/guides/ | yes | guides only (95 items) |
/category/ai/, /category/tech/, etc. | no | n/a — category pages are small enough to scan visually |
/books/the-agent-advantage/ | yes (tool directory) | tool directory sub-component |
| Individual post pages | no | n/a — already inside the article |
The component is src/components/SearchBar.astro. It accepts a scope prop (news, reviews, guides, all), an inputId, and a postsIndex array, and renders the <form> + the <script type="application/json"> block.
The JSON index
Every archive page emits an <script type="application/json" id="abs-search-index">[…]</script> block in the document head. The contents are a minimal projection of every post the page should be searchable across:
[
{
"slug": "gpt-red-unlocking-self-improvement-for-robustness",
"type": "news",
"category": "ai",
"title": "OpenAI's GPT-Red Pits One AI Against Another. The Defender Usually Wins.",
"description": "GPT-Red runs an attacker model and a defender model in self-play...",
"pubDate": "2026-07-15T00:00:00.000Z",
"image": "/images/gpt-red-unlocking-self-improvement-for-robustness.png",
"tags": ["openai", "gpt-red", "ai-safety", "red-teaming", "prompt-injection"]
},
...
]
That array ships once per page. On /news/1/, it is ~50 KB (157 items, ~300 bytes each). On /, it is ~150 KB (all 284 items). It is the only state the search bar reads.
The script
SearchBar.astro includes a small IIFE in the document body that:
- Wires
inputevents on the search input to arequestAnimationFrame-throttled filter function. - Loops over
data-abs-search-slugrows in the DOM, setsdisplay: noneif the slug is not in the filtered set. - Updates an
aria-live="polite"status region with the result count (“12 results” or “no matches”).
Total runtime: ~40 lines of vanilla JavaScript, no framework, no bundler.
(function () {
const input = document.querySelector('[data-abs-search-input]');
const status = document.querySelector('[data-abs-search-status]');
if (!input) return;
const index = JSON.parse(
document.getElementById('abs-search-index').textContent
);
// Build a lookup table keyed by slug
const bySlug = Object.fromEntries(index.map((p) => [p.slug, p]));
const rows = Array.from(document.querySelectorAll('[data-abs-search-slug]'));
let pending = 0;
input.addEventListener('input', () => {
if (pending) cancelAnimationFrame(pending);
pending = requestAnimationFrame(() => {
const q = input.value.trim().toLowerCase();
let visible = 0;
for (const row of rows) {
const slug = row.getAttribute('data-abs-search-slug');
const post = bySlug[slug];
if (!post) continue;
const haystack = (
post.title + ' ' + post.description + ' ' + (post.tags || []).join(' ')
).toLowerCase();
const match = q === '' || haystack.includes(q);
row.style.display = match ? '' : 'none';
if (match) visible++;
}
if (status) {
status.textContent = q === ''
? ''
: `${visible} ${visible === 1 ? 'result' : 'results'}`;
}
});
});
})();
This script is identical across all four pages. The only difference between pages is the contents of the JSON index.
Why this is “cheap”
The cost on first page load is one extra <script type="application/json"> tag. The browser parses it lazily — the cost is paid when the user types the first character, not on initial render. The filter loop walks O(n) rows on every keystroke, throttled to one filter per animation frame (≤16 ms). For 157 items, the filter completes in <2 ms in practice. The user feels instant feedback.
The cost on the server is zero. There is no /api/search endpoint to maintain, no Lucene index to keep in sync, no Elasticsearch cluster to provision. The site is a static Astro build that deploys via GitHub Pages. Adding a search backend would have meant either:
- A separate service to query (Cloudflare Worker + D1 SQLite, or Algolia) — adds $20–$100/mo and a new failure surface.
- A client-side minisearch index — bigger than the JSON approach, slower on first load, requires WASM.
Neither was worth it for a 284-post site. If the archive grows past ~5,000 posts, the JSON approach’s load-time cost will cross a threshold and we will revisit.
Edge cases we handled
- Search across pages. The
/news/2/page’s JSON index contains all 157 news posts, not just the 30 visible on that page. So searching “Gemini” on/news/5/will show Gemini stories from/news/1/. The visible-row hiding still works because the rows on page 5 don’t include those slugs — but the status counter and the result count include them. - Case-insensitive. Trivial — both query and haystack are
.toLowerCase(). - Whitespace.
trim()on the query, thenincludes(q)on the haystack. A query of" "is treated as empty. - Tags. Tags are joined with spaces into the haystack, so
tag:anthropicworks the same as searching for “anthropic” in title/description. - No results. Status region shows “no results”. Rows stay visible (none are hidden because no row matches) — wait, that’s wrong: if no row matches, every row is hidden. The page looks empty. Test for that.
The “no results” empty state is currently a status-line message. The page looks empty, which is the right affordance (the user knows nothing matched), but it can be visually confusing. A future improvement: render a “no matches for X” empty-state row inside the list.
What it does NOT do
- No fuzzy matching. “Gemni” will not match “Gemini”. For 284 posts, an exact substring match is fast enough that fuzzy adds complexity for marginal gain.
- No ranking. Results are in DOM order, which is
pubDate descfor news,grade descfor reviews,pubDate descfor guides. If you search “Gemini” on the news page, you get every Gemini story in date order. That happens to be a useful default. - No highlighting. The matched substring is not bolded inside the result text. A future improvement.
- No keyboard nav. Arrow keys don’t navigate the result rows. Mouse only. For 157 items max, acceptable.
When to revisit
The JSON approach scales linearly with n. The threshold where a real search backend becomes necessary is roughly:
n > 5,000: index size crosses 1.5 MB; first paint penalty noticeable on slow networks.n > 50,000: even a debounced filter is visibly slow (>100 ms) on a mid-range laptop.- Search needs typo tolerance or synonym expansion: pure substring can never give this; needs an inverted index.
ABS is at 284 posts. We have headroom.
Verification
/news/1/— type “Gemini” — 1 row stays visible (the Gemini 3.5 Live Translate story). All other rowsdisplay: none./news/1/— type “anthropic” — 3 rows stay visible (Anthropic-pain, Claude inner workings, etc.)./reviews/— type “usb” — Yereadw USB tester review appears./guides/— type “vllm” — the vLLM setup guide appears./(homepage) — type “abs” — 95 ABS-internal guides stay visible; news/reviews hide.
Each of these is reproducible on the live site today.
Anti-patterns
- A search-results modal. Hides the page, requires focus management, requires a backend or large client-side index. Don’t.
- A debounced fetch per keystroke. Every debounced fetch is a network tab entry. The JSON-on-page approach has zero fetches.
- Highlighting the matched substring with
<mark>. Sounds nice, costs DOM churn. Skip. - Routing to
/search?q=.... Means a server-side template render and a separate page. The inline filter is faster and simpler. - A minisearch.js WASM bundle. Adds ~150 KB to the page and a worker. Overkill for 284 posts.
Files
src/components/SearchBar.astro— the component (input + IIFE script + JSON index tag)src/pages/news/index.astro— emits the news-only index (~50 KB)src/pages/news/[page].astro— emits the same global index per paginated pagesrc/pages/reviews/[sort].astro— emits the reviews-only indexsrc/pages/guides/index.astro— emits the guides-only indexsrc/pages/index.astro— emits the full-collection index for the homepage
The component is a leaf — no skill or config required to use it. Drop it on any page that wants a search bar, pass a postsIndex prop, and you’re done.
Last verified: 2026-07-15 against the live ABS build at commit bcc14ed.



Submit a take
Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.