Search usually implies a server. A box takes a query, a backend runs it against an index, results come back. This site has none of that. It exports to static HTML and ships to a plain host, so there is nothing to ask a question to at request time. The usual shape of search does not apply.
The way through is to notice what a static export already is: a folder of finished HTML. Every page the site will ever serve exists on disk the moment the build finishes. That folder is searchable, and searching it is the whole trick.
Index the output, not the source
The search index is built after the export, against the built pages, not against the MDX they came from. Pagefind runs over the out/ directory once the site is compiled, reads all 230 pages, and writes a set of prefix-sharded index fragments plus a small WebAssembly engine into out/pagefind/. The index ships as part of the site.
Indexing the output rather than the source has a property I did not fully appreciate until I tested it. What the search can find is exactly what the site published, because they are the same artefact. A hidden post is excluded from the build, so it is absent from the HTML, so it is absent from the index. There is no separate “also hide it from search” step to forget. I confirmed it against the real index: searching for a term that only appears in an unpublished devlog post returns nothing, because that post is not in the output at all.
Load nothing until asked
Shipping an index with every page could be heavy, so none of it loads eagerly. The engine and the shards sit in out/pagefind/ doing nothing until the search dialog opens. When you type, the engine fetches only the shards whose terms match your query. A search for one word pulls a few kilobytes, not the whole index.
There is a build-tooling wrinkle in that. The Pagefind bundle exists only in the deployed site, never in the source tree, so a normal import would fail at compile time when the file is not there yet. The dialog imports it through a variable specifier marked to be ignored by the bundler, so TypeScript and webpack leave it alone and the browser resolves it at runtime against the real deployed path.
Let it fail safe
The index build is deliberately non-gating. It runs with continue-on-error, ordered before the artefact upload so a successful index ships, and a failed one never fails the deploy. If the index is missing, the dialog degrades to a static /search/ fallback page that lists the same content, held out of the sitemap and marked noindex. Search breaking is an inconvenience. It is not allowed to become a broken deploy.
Two things the build corrected
The spec I wrote to make WASM work included an htaccess rule setting the application/wasm MIME type. It does nothing. Pagefind ships its WebAssembly gzipped under its own extension and loads it via fetch to an array buffer and compile, which never consults the MIME type. The rule I added to enable WASM was inert, because WASM already worked. I replaced it with a comment explaining why there is nothing to configure.
The second correction was sharper. Search is the first place a visitor types into this site, and the first version rendered result excerpts with dangerouslySetInnerHTML to show Pagefind’s highlight marks. A review hook flagged it immediately. The replacement splits the excerpt on the plugin’s <mark> tags and decodes entities by reading textContent off a detached parser with scripting disabled, so nothing HTML-shaped from the index is ever attached to the live DOM. On the one surface that accepts user input, there is no HTML sink at all.
The pattern
For a static site, the output is the database. Index it after the build, load it lazily so nothing is paid for until someone searches, let the index fail without taking the deploy with it, and check that the config you wrote to make it work is actually doing something. Mine, in one case, was not.


