How I built full-text search for my static site

Every static site eventually wants a search box. Mine got one the hard way: I tried three client-side libraries, a hosted service, and one regrettable iframe before accepting that the right answer was a tiny index built at compile time and shipped as a single site.search.json file.

The design notes for the final approach fit on a napkin: no server, no runtime dependency heavier than quarry-query, and results in under 16 ms on a mid-range phone. This post walks through the whole build so you can steal it.

Picking an Index Format

There are really only three shapes a static index can take: a raw array of documents you filter with includes, a prebuilt inverted index, or a compressed trie. The array is fine until about forty posts; the trie is fast but miserable to debug. I went with the inverted index, generated by quarry-index as a build step:

npm install quarry-index quarry-query --save-dev

Both packages are dependency-free, and the query half weighs about 2 KB gzipped—small enough to inline into the page and skip a network round-trip entirely.

Constraints & Context

The site is plain HTML on a CDN—no functions, no edge workers, no place to put an API. Whatever answered a query had to already be in the browser. That single constraint eliminated every hosted option in one move.

I set two budgets before writing any code: the index had to stay under 50 KB gzipped for a ~180-post archive, and the first keystroke had to return results without a spinner. Everything below falls out of those two numbers, plus one tool: quarry-index.

Step 1 — Build the Index at Compile Time

The build step is one config file, search.config.mjs, read by the CLI after every content change. It walks the markdown sources, tokenizes each field, and writes the packed index next to the rest of the static assets:

// search.config.mjs
import { defineIndex } from "quarry-index";

export default defineIndex({
  content: "content/**/*.md",
  fields: ["title", "headings", "body"],
  boost: { title: 3.0, headings: 1.6 },
  minWordLength: 3,
  output: "public/site.search.json",
});

Keeping the index under 50 KB

Three tricks did the heavy lifting: stemming (so “deploying” and “deployed” share one entry), a 120-word stopword list, and delta-encoding document ids so the JSON compresses well. Each term ends up as a compact posting like this:

{
  "term": "deploy",
  "docs": [
    { "id": 14, "score": 0.82 },
    { "id": 31, "score": 0.47 },
    { "id": 60, "score": 0.22 }
  ],
  "df": 3
}

The full archive landed at 41 KB gzipped—under budget, with room for another year of posts.

Step 2 — Wire Up the Query Parser

On the client, quarry-query turns whatever the user types into the same token stream the build produced. Quoted phrases stay intact, the trailing word becomes a prefix match so results appear mid-keystroke, and unknown terms are dropped instead of zeroing out the whole query.

The parser is deliberately forgiving. Search boxes get typos, plural forms, and half-remembered titles—punishing any of that just teaches people the box is broken.

Step 3 — Rank the Results

Scoring is weighted term frequency: a hit in the title is worth three body hits, a heading hit about one and a half—exactly the boost values from the config. Ties break on recency, because on a blog the newer post is almost always the one people mean.

The top eight results render into a plain list under the input. No virtualized dropdown, no portal. At eight items, the DOM is not your bottleneck.

Step 4 — Ship It

The index loads lazily: nothing is fetched until the search input receives focus, which warms the cache during the ~600 ms people spend deciding what to type. After the first load it sits in sessionStorage, so every later page in the visit searches instantly—even offline.

Total cost: one build step, 41 KB over the wire once per visit, and zero servers. The search box has outlived two redesigns since.

9:41