-
Step 1 · Pick an approach: build-time index / own inverted index / hosted search
Choose by scale before wiring up a paid search API
There are only three ways to add search to a static site, and picking wrong wastes a week.
| Approach | How | Fits | Cost |
|---|---|---|---|
| Build-time index (Pagefind and friends) | Scan the HTML before publishing, generate an index, query it with fetch | A dozen to a few thousand pages | Re-run the index on every content change |
| Your own inverted index | Build an index in the browser from JSON you already have | Content is already JSON-driven, few pages (tens) | You handle Chinese word segmentation yourself; ranking is rough |
| Hosted search (Algolia and friends) | Push data to someone else's server, call the API | Tens of thousands of pages, typo tolerance and synonyms needed | Quotas, syncing, and latency varies by region |For a site this size (20-odd pages, data already in JSON) the first one is right: index at build time, deploy one extra directory, no backend, no database, and nothing depends on a third party staying alive.
Confirm one prerequisite first: the indexer reads the final HTML, not the page as you imagine it. If the body is rendered by JS from JSON at runtime (this site's wizard steps used to work that way), both crawlers and indexers see an empty shell — so the content has to be static before this step. On this site
scripts/seo-prerender.jswrites the step bodies into the HTML, which is what makes the index worth building at all.Decision: under 50 pages with the content already in JSON? Jump to step 4, option C, and skip a build command entirely.
Checklist
- One approach chosen, with a reason written down
- Confirmed the page body is static HTML, not rendered by JS at runtime
- You know the index must be rebuilt on every publish
-
Step 2 · Scope the body: only what should be findable gets indexed
Two attributes keep the nav, footer and promo slots out
By default the indexer swallows every word on the page — navigation, footer, promo slots, disclaimers. Search a tool name and you get 30 hits that are all footers.
Two attributes fix it:
data-pagefind-body: put it on the content container. One per page — once it's present the indexer only collects inside it (no attribute means the whole page gets indexed)data-pagefind-ignore: put it on the blocks you want excluded (nav, footer, promo slots,noscriptnotes)
<main data-pagefind-body> <h1>Title</h1> <p>The body text that should be findable…</p> </main> <nav data-pagefind-ignore>Navigation</nav> <footer data-pagefind-ignore>Footer and disclaimer</footer>Tag the metadata while you're there, so results read well and can be filtered:
data-pagefind-meta="category:site building"→ an extra line in the resultdata-pagefind-filter="category:site building"→ lets people filter by categorydata-pagefind-index-attrs="alt,title"→ image alt text goes into the index too
Title and excerpt:
<h1>becomes the title and the first paragraph after it the excerpt. Override withdata-pagefind-meta="title:…"/data-pagefind-meta="summary:…".What this site does: scenario landing pages put only three blocks inside
data-pagefind-body— the step bodies, the FAQ and related scenarios. Top nav, footer promo andnoscriptare alldata-pagefind-ignore.Verify: after generating the index, search a word that only appears in the footer (try "disclaimer") — you should get zero results.
Checklist
- The body container has data-pagefind-body, and there's only one per page
- Nav / footer / promo slots have data-pagefind-ignore
- A footer-only word returns no results
-
Step 3 · Generate the index: one command, plus Chinese segmentation
Nail the index into your release flow instead of remembering to run it
One command builds the index (run it in the site root, the level where
index.htmllives):npx pagefind --site .You get a
pagefind/directory full of static files:pagefind.js/pagefind-ui.js: querying plus the official UIindex/*.pf_indexandfragment/*.pf_fragment: index shards, loaded on demand — the browser only downloads what the current query needs, so nothing drags down first paint
Chinese: setting the language explicitly avoids mis-detection, via
--force-language zh-cn. If the tool rejects that language code, drop the flag — the indexer handles CJK on its own. Trustnpx pagefind --helpover any blog post.Index only what should be indexed: it scans HTML files by default; use
--globto narrow the scope (for example--glob "**/*.html").scripts/anddata/have no business in the index.Rebuild the index on every content change — the step everyone forgets. Nail it into the release flow; this site's order is fixed:
node scripts/seo-prerender.js # 1. make the content static (bodies into the HTML) npx pagefind --site . # 2. generate / refresh the index bash scripts/deploy-site.sh # 3. upload, pagefind/ includedThe order isn't negotiable: static first, index second. Reverse it and you index empty shells.
Preview locally:
npx pagefind --site . --serve, or serve the site root with any static server and try a search.Checklist
- pagefind/ exists and contains pagefind.js
- The index command is part of the release flow, not a memory exercise
- The local preview returns results
-
Step 4 · Wire it into the page: official UI or your own rendering
Two ready-made options plus a zero-dependency one
Option A: the official UI (working in ten minutes)
<link rel="stylesheet" href="/pagefind/pagefind-ui.css"> <script src="/pagefind/pagefind-ui.js"></script> <div id="search"></div> <script> new PagefindUI({ element: '#search', showSubResults: true, translations: { placeholder: 'Search this site', zero_results: 'Nothing found — try another term' } }); </script>Option B: render it yourself (for custom styling)
async function runSearch(term) { const pagefind = await import('/pagefind/pagefind.js'); const r = await pagefind.search(term); const data = await Promise.all(r.results.slice(0, 10).map((x) => x.data())); return data.map((d) => ({ title: d.meta.title, url: d.url, excerpt: d.excerpt })); }Don't let search slow down first paint:
import()pagefind.jsthe first time someone focuses the search box, and not a byte loads before that.Option C: a hand-rolled inverted index with zero dependencies (best under ~50 pages when the content is already JSON)
This site's scenario data already lives in
data/scenarios.jsonanddata/steps.json, so it's searchable without any library:// Build: split each record's title + body into terms, record where each appears function buildIndex(docs) { const idx = new Map(); docs.forEach((d, i) => { const text = (d.title + ' ' + d.summary + ' ' + d.content).toLowerCase(); const words = text.match(/[a-z0-9]+|[\u4e00-\u9fa5]{1,2}/g) || []; words.forEach((w) => { if (!idx.has(w)) idx.set(w, new Set()); idx.get(w).add(i); }); }); return idx; } function query(idx, docs, term) { const words = term.toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fa5]{1,2}/g) || []; const score = new Map(); words.forEach((w) => (idx.get(w) || []).forEach((i) => score.set(i, (score.get(i) || 0) + 1))); return [...score.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([i, n]) => ({ doc: docs[i], hit: n })); }On Chinese: the crude "1–2 characters per token" bigram split above is enough for a few dozen pages — it matches substrings rather than words. For real accuracy use Pagefind or a segmentation library; don't build your own tokenizer.
Fallback: with JS off, search doesn't work — but the body text is static and still readable. Search is an enhancement, never the only way in.
Checklist
- The search box returns results
- pagefind.js is lazy-loaded and doesn't slow down first paint
- Content is still readable with JS disabled
-
Step 5 · Launch and observe: don't let search pages steal index slots
Deployed means the directory is served; zero-result terms are your editorial backlog
Upload:
pagefind/is plain static files — send it up with the HTML. Then openyourdomain/pagefind/pagefind.jsandyourdomain/pagefind/pagefind-ui.jsdirectly in a browser — those two loading is what "deployed" means. If they don't, the server is probably blocking the directory (check the directory access rules in your panel's site config).Keep search result pages out of the index: they're infinitely varying low-quality pages, and submitting them only drags down your indexed ratio:
- Put
<meta name="robots" content="noindex,follow">in the search page's<head> - Or add
Disallow: /searchtorobots.txt - Don't put search pages in
sitemap.xml(this site's sitemap, generated byseo-prerender.js, only includes main pages and scenario landing pages)
Three things to observe (definitions go in
data/conversion.json, using the same tracking as everything else):search_query: log the term whenever someone searches — no personal datazero_result_rate: the share of searches that return nothing- The share of searches that click through into a page
Zero-result terms are the most useful output: if a tool name returns nothing, either you have no content on it or the word isn't in your titles — that list is literally your editorial backlog. On this site the
ai-writingscenario (topic → draft → review → publish → measure) consumes exactly this input.Acceptance: pick 5 terms you know exist on the site (scenario names, tool names, step titles) and search each one. 5/5 must hit. When content changes, rebuild the index — put it on the release checklist next to minifying CSS and JS.
Checklist
- /pagefind/pagefind.js opens directly in a browser
- The search page is noindex and absent from the sitemap
- search_query and the zero-result rate are instrumented
- 5 sampled terms, 5 hits
- Put