Search without a server

The search box is a <dialog> with an input, a list, and about a hundred lines of JavaScript. There is no search service and no API call at query time.

The build writes a search index; the browser fetches it once and searches locally.

The index

At build time, every post becomes one entry:

{
  "title": "Search without a server",
  "url": "/posts/search-without-a-server/",
  "date": "2026-09-12",
  "tags": ["search", "javascript"],
  "description": "How the search overlay works...",
  "text": "The search box is a dialog..."
}

The text field is the rendered post with the HTML stripped out. It is capped at 12 KB per post, which is plenty for matching and small enough that the whole index stays quick to parse.

The matching

Queries are split into tokens and normalised. Each token must match somewhere; title matches are worth more than body matches:

export function scorePost(post, tokens) {
  const title = normalizeText(post.title);
  const text = normalizeText(post.text);
  let score = 0;
  for (const token of tokens) {
    let points = 0;
    if (title === token) points += 40;
    else if (title.startsWith(token)) points += 24;
    else if (title.includes(token)) points += 16;
    if (text.includes(token)) points += 2;
    if (!points) return 0;
    score += points;
  }
  return score;
}

Prefix matching means partial words work: typing stat already finds this post. Requiring every token keeps multi-word queries precise.

Fuzzy matching is lovely for command palettes and frustrating for prose. A blog search usually knows what it is looking for: a title, a tag, or a phrase. Exact substring matching is predictable, explainable, and fast enough that the results appear between keystrokes.

The index is fetched the first time the dialog opens, and cached for the rest of the visit. Until then, it costs nothing.

Search Robert Marin

↑↓ navigate Enter open Esc close