Static Architecture Signal vs Noise Developer Experience (DX) Next.js

Static-Site Search With Pagefind: You Don't Need Algolia

Static Signal
A vast brass card-catalog cabinet in a dark steampunk archive, thousands of index drawers glowing with neon copper filaments, mechanical query arms converging on a single illuminated card while violet energy traces ripple along the cabinet seams

If your site is static, paying $50 a month for Algolia is a self-inflicted wound.

That wasn’t always true. For a long time, “real” search on a static site meant one of three things: ship a 200KB Lunr index that scaled like a brick, embed Algolia and accept the SaaS dependency, or stand up Elasticsearch and admit you never wanted a static site in the first place. None of those were good. They were just the options.

Pagefind made the third path the obvious one. It builds the index from your already-rendered HTML, ships it as static files alongside your site, and loads at runtime from the same CDN that’s serving your pages. No API key. No monthly bill. No backend.

This site runs it. The search you triggered with ⌘K to find this post is the worked example.


What Pagefind Actually Does

Pagefind is a CLI that reads your built HTML output, tokenizes the visible text, and emits a pagefind/ directory containing:

  • A small JavaScript entry (pagefind.js) that loads on demand
  • A WebAssembly module that does the actual searching
  • A sharded index — one fragment per page — that the WASM module fetches lazily as queries narrow

When a user types in your search box, the browser loads the index fragments it needs and runs the query locally. The “server” is just static-file hosting. Your CDN is doing the work it was already doing.

The index for this site — 27 posts, ~5,000 words — clocks in at under 100KB total, sharded so the browser only downloads what a given query touches. That’s smaller than most hero images.


Wiring It Into a Next.js Static Export

The setup is one devDependency and one extra command in the build script.

npm install --save-dev pagefind

Then in package.json:

{
  "scripts": {
    "build": "next build && pagefind --site out --output-subdir pagefind"
  }
}

That’s it for the build side. next build writes the static export to out/. pagefind --site out walks that directory, indexes every .html file it finds, and writes the index to out/pagefind/. When you deploy out/ to your CDN, the index ships with the rest of the site.

There is no separate index-update step. There is no webhook. The index is regenerated every build because rebuilding the entire site is cheap when the site is static.


The Lazy-Loaded Client

Pagefind doesn’t ship a UI. You write your own. The trick is loading the WASM module only when the user actually opens search — there’s no point paying the bytes on every page load.

A dynamic import does the job:

'use client'

import { useEffect, useRef, useState } from 'react'

const PAGEFIND_URL = '/pagefind/pagefind.js'

export function SearchDialog({ open }: { open: boolean }) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'unavailable'>('idle')
  const pagefindRef = useRef<PagefindModule | null>(null)

  useEffect(() => {
    if (!open || status !== 'idle') return
    setStatus('loading')
    ;(async () => {
      try {
        const mod = (await import(/* webpackIgnore: true */ PAGEFIND_URL)) as PagefindModule
        pagefindRef.current = mod
        setStatus('ready')
      } catch {
        setStatus('unavailable')
      }
    })()
  }, [open, status])

  // ...input, results, keyboard nav
}

Two things matter in that snippet.

First, webpackIgnore: true. Pagefind generates pagefind.js at build time, after Next.js has finished bundling. If you don’t tell webpack to ignore the import, the bundler tries to resolve a file that doesn’t exist yet and the build fails. The magic comment turns the import into a runtime fetch from the CDN.

Second, the 'unavailable' status. In next dev, there is no out/pagefind/ directory because there is no static export. You want a graceful fallback (“search isn’t available in dev”) rather than an unhandled promise rejection.

Once the module is loaded, querying it is two lines:

const response = await pagefindRef.current.debouncedSearch(query, {}, 120)
const results = await Promise.all(
  response.results.slice(0, 8).map(async (r) => ({ id: r.id, ...(await r.data()) })),
)

The two-stage fetch — search() returns lightweight result handles, r.data() resolves a single fragment — is intentional. It lets you render eight results without downloading metadata for the other 200 the index might match.


The CSP Gotcha That Will Bite You

This is the part the Pagefind docs gloss over.

If you ship a Content-Security-Policy header — and you should — Pagefind will fail silently in production. Two directives need to be permissive enough:

  • script-src must allow 'wasm-unsafe-eval'. The WASM module instantiates inside the JS execution context.
  • worker-src must allow 'self' blob:. Pagefind spawns a Web Worker from a blob URL to keep search off the main thread.

The default CSP from most starter templates allows neither. The symptom is that search loads, the input takes focus, and queries return zero results forever. There is no console error you’d recognize as a CSP violation — just a quiet failure inside the WASM bootstrap.

The header on this site looks like this:

default-src 'self';
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cloud.umami.is;
worker-src 'self' blob:;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
connect-src 'self' https://cloud.umami.is;

The two directives that matter for Pagefind are bolded in your head, not in the header. If you’re testing locally and search works, then deploying breaks it, this is almost always the cause.


Cost: Pagefind vs Algolia

Numbers, because the trade-offs aren’t abstract.

Algolia charges per “record” (each searchable document) and per “operation” (each query). The free tier covers 10,000 records and 10,000 operations per month. The next paid tier — Build — starts at $0.50 per 1,000 records over the threshold and $0.40 per 1,000 operations. A blog with 500 posts and modest traffic blows through the free tier in days. Annualized, you’re looking at $100 to $600 per year for a hobby site, more for anything trafficked.

Pagefind charges $0. It is a CLI under the MIT license. The marginal cost of indexing another 500 posts is the few seconds Pagefind takes to walk the build output. The marginal cost of another 10,000 queries is whatever your CDN charges for serving static assets — which, on Cloudflare or Render’s free tier, is also $0.

You pay in bundle size: ~50KB for the entry script, plus the index fragments fetched on demand. For most static sites, that’s smaller than the analytics tag they already ship.


When Pagefind Isn’t the Right Fit

Don’t oversell. Pagefind has real limits.

Typo tolerance is basic. Algolia’s “good enough” fuzzy matching handles transposed letters, missing characters, and phonetic variants in ways Pagefind doesn’t. If your audience misspells things often, Algolia’s matching is genuinely better.

No analytics. You won’t get a dashboard of “what people searched for and didn’t find.” If that signal matters for content strategy, you’ll need to wire up your own logging — easy enough on the client side, but it’s not free out of the box.

No synonyms config. Algolia lets you say “node” should match “nodejs” without indexing changes. Pagefind matches what’s literally in your HTML. You can sometimes solve this by adding aliases as data-pagefind-meta attributes, but it’s manual.

Multi-language sites need extra work. Pagefind handles per-language indexes well, but you have to mark up lang attributes correctly and configure language detection. It’s not the zero-config experience the single-language case is.

If any of those four are core requirements, Algolia or Typesense earns its keep. For everything else — which is most static sites — Pagefind is the answer.


The Mental Shift

The interesting part of Pagefind isn’t the technology. It’s what it represents.

Search was the last hard thing on a static site. Forms, comments, auth, image optimization, OG cards, RSS — every one of those used to require a server and now doesn’t. Search held out longer because the index is genuinely a data structure, and data structures want to live next to compute. Pagefind solved that by shipping the compute (WASM) and the data (sharded index) as static files, and trusting the browser to be the runtime.

That pattern — “ship the runtime to the client, serve everything else as static files from the CDN” — is the same pattern that gave us static-first forms, edge functions, and build-time OG cards. Search is just the latest thing to fall to it.


The Takeaway

Search was the last excuse for adding a backend to a static site. That excuse is gone.

Install one devDep. Add one line to your build script. Write a 50-line client. Patch your CSP. Ship.

If you’re paying for Algolia on a static site today, your renewal is the next decision worth questioning.


Static Signal is published by Neuron Web Development.