← Back to blog Khalil Drissi

How to add full-text search to a static site

Listen to article
0:00

The first objection I hear is that static sites cannot have search because there is no server to query. That is wrong. You can build a search index when the site is generated and ship it as a plain file, then search it entirely in the browser. For anything up to a few thousand documents, it is fast enough that users assume there is a backend.

Why client-side search works

The trick is moving the work to build time. While my generator is already looping over every page to render HTML, it is trivial to also collect the title, URL, and body text of each one into a list. That list becomes a JSON file. The browser downloads it once, builds an in-memory index, and every keystroke after that is instant because nothing leaves the device. No database, no API, no per-query cost.

Build the index at generation time

During the build, I strip HTML tags from each page and push a small record into an array. Keep the body text trimmed; you do not need every word, and a leaner index downloads faster. Here is the gist of it:

const index = pages.map(page => ({
  title: page.title,
  url: page.url,
  excerpt: page.excerpt,
  body: page.text.slice(0, 2000)
}));

fs.writeFileSync('dist/search-index.json', JSON.stringify(index));

Writing this file is just one more step in the same pipeline that produces your pages, so it slots naturally into a build you may already run through CI/CD with GitHub Actions. The index ships alongside your HTML to the same edge network.

Pick a search library, or write the dumb version

For small sites you genuinely can write your own matcher in a dozen lines. Lowercase everything, split the query into words, and score documents by how many words they contain. It works. But the moment you want typo tolerance, prefix matching, or relevance ranking, reach for a library. I like Fuse.js for fuzzy matching and MiniSearch when I want proper full-text scoring. Both are tiny and run in the browser without a build step.

My rule of thumb is to start with the hand-written version and only upgrade when users complain. Most of the time the simple matcher is more than enough, and you avoid shipping a dependency you do not need. When you do switch to a library, the index format stays the same, so the migration is a few lines, not a rewrite.

import MiniSearch from 'minisearch';

const res = await fetch('/search-index.json');
const docs = await res.json();

const mini = new MiniSearch({
  fields: ['title', 'body'],
  storeFields: ['title', 'url', 'excerpt']
});
mini.addAll(docs);

const results = mini.search('cloudflare deploy', { fuzzy: 0.2 });

Wire it to the input

Hook a listener to your search box, but do not search on every single keystroke. Debounce it by 150 milliseconds or so, otherwise a fast typist fires a dozen searches for one word. On each debounced event, run the query, take the top handful of results, and render them as a list of links. Show the title and the excerpt so people can tell which result they want before clicking.

Load the index lazily

Do not download the search index on page load. Most visitors never search, so paying that cost upfront is wasteful. I fetch the JSON the first time someone focuses the search box, cache it in a variable, and reuse it. The first search has a tiny delay while the file arrives, every search after is instant, and people who never search never pay a byte for it. This keeps your initial load lean, which matters for the same reasons I obsess over in optimizing images for the web.

When to stop and use a service

Client-side search has a ceiling. Past roughly ten thousand documents the index gets large enough that downloading and parsing it hurts, especially on phones. At that scale I switch to a hosted search service that exposes an API. But honestly, most blogs and docs sites never come close to that limit. Build the index, ship the JSON, search in the browser, and you get a feature that feels expensive for almost no cost and zero servers to maintain.

Comments
Leave a comment