This is part 8 of a series on preparing notACMS for open-source release. Part 7 covered building a production theme. The original WordPress to Symfony series covers the migration itself.


Search on a static site. No Elasticsearch. No Algolia. No PHP search endpoint. Just a set of static files and a JavaScript library that compiles to WebAssembly. Here is how it works, what it costs, and what breaks in production.

What Pagefind is

Pagefind is a Rust-based CLI tool by CloudCannon, MIT licensed. At build time, it indexes pre-rendered static HTML files. At runtime, a JavaScript client loads a compiled WASM binary and runs queries entirely in the browser. Zero server-side cost — the index is just static files. No API key, no usage limits, no infrastructure.

The indexing pipeline

Pagefind runs after app:build has produced all static HTML in public/static/:

npx pagefind --site public/static --output-path public/pagefind

The process:

  1. Scans every HTML file in public/static/ for data-pagefind-body regions
  2. Extracts text content, heading structure, and metadata from data-pagefind-meta attributes
  3. Detects the page language from the HTML lang attribute (<html lang="en">)
  4. Builds a per-language WASM binary search index — English pages go into wasm.en.pagefind, Polish pages into wasm.pl.pagefind
  5. Generates per-page metadata files (pagefind.en_*.pf_meta) — one per indexed page, per language
  6. Writes the JavaScript runtime (pagefind.js), the Web Worker (pagefind-worker.js), and a metadata manifest (pagefind-entry.json)

For a ~170-page site with 2 languages (English + Polish), this produces roughly 437 files:

public/pagefind/
├── pagefind.js                   ← JS runtime (loaded via dynamic import)
├── pagefind-entry.json           ← language → hash → page count mapping
├── pagefind-highlight.js         ← client-side text highlight engine
├── pagefind-worker.js            ← Web Worker for parallel queries
├── wasm.en.pagefind              ← English search index binary
├── wasm.pl.pagefind              ← Polish search index binary
├── fragment/                     ← chunked index fragments
├── index/                        ← index metadata
├── pagefind.en_*.pf_meta         ← per-page English metadata (~170 files)
└── pagefind.pl_*.pf_meta         ← per-page Polish metadata (~170 files)

The pagefind-entry.json manifest:

{
    "version": "1.5.2",
    "languages": {
        "en": {"hash": "en_cca8c68272", "wasm": "en", "page_count": 170},
        "pl": {"hash": "pl_8f57265768", "wasm": "pl", "page_count": 170}
    }
}

Each language is a completely independent index. The client loads only the WASM binary for the current locale's language.

The data-pagefind-* contract

Three HTML attributes control what gets indexed and what appears in search results. They are placed per content template, not in the base layout — each page type decides what is searchable.

data-pagefind-body

Placed on the main content container. Tells Pagefind: index everything inside this element.

Post template:

<article data-pagefind-body>
    <h1>{{ content.title() }}</h1>
    <div class="prose">{{ content.body() }}</div>
</article>

Every content template sets this on its own article/div wrapper. The search page explicitly opts out:

<div class="search-page" data-pagefind-ignore="all">

Prevents the search results page from indexing itself — a real problem for sites where Pagefind indexes search results snippets.

data-pagefind-meta

Injects structured metadata into the search index. Accessible as result.meta.* in client-side JS. Post template:

<article data-pagefind-body>
    <h1 data-pagefind-meta="title">{{ content.title() }}</h1>
    <span style="display:none" data-pagefind-meta="date">{{ content.dateString() }}</span>
    <span style="display:none" data-pagefind-meta="category">{{ content.category(locale) }}</span>
    <span style="display:none" data-pagefind-meta="tags">{{ content.tags()|join(',') }}</span>
</article>

The responsive image component injects image[src] metadata when requested:

{# responsive_img.html.twig #}
<img src="{{ src }}" alt="{{ alt }}"
     {% if pagefind ?? false %}data-pagefind-meta="image[src]"{% endif %}>

Callers pass pagefind: true:

{# featured_image.html.twig #}
{{ include('components/responsive_img.html.twig', {
    src: item.image(),
    alt: item.imageAlt(),
    pagefind: true
}) }}

Pagefind stores the image URL, which the search JS can read to render thumbnails in results.

data-pagefind-ignore

Excludes content that should not appear in search text:

<div class="post-tags" data-pagefind-ignore>
    {# tag pills — user navigation, not post content #}
</div>

<section class="related-posts" data-pagefind-ignore>
    {# cross-referenced content is not the current post's content #}
</section>

<details class="series-nav" data-pagefind-ignore>
    {# series navigation widgets — pollute search text #}
</details>

<nav class="post-navigation" data-pagefind-ignore>
    {# prev/next links — not post body #}
</nav>

Without these ignores, every Pagefind search would return results filled with navigation boilerplate and widget text.

Two search UIs

notACMS ships two client-side search implementations. Both load Pagefind lazily and debounce queries. They serve different interaction patterns.

UI A: Standalone search page (/search/?q=...)

File: assets/search.js (103 lines). Used on the dedicated /search/ page.

Configuration via data attributes:

{# search/index.html.twig #}
<div id="search"
     data-placeholder="{{ 'search.placeholder'|trans }}"
     data-zero-results="{{ 'search.no_results'|trans({'%query%': '[SEARCH_TERM]'}) }}"
     data-read-more="{{ 'blog.read_more'|trans }}"
     data-tag-base="{{ path('blog_tag_' ~ locale, {tag: 'tag-placeholder'}) }}"
     data-image-widths="{{ image_variant_widths|join(',') }}">
</div>

The [SEARCH_TERM] token is replaced at render time. The tag-placeholder token is replaced with each result's tag slug.

Lazy loading:

import('/pagefind/pagefind.js').then(function (pf) {
    pagefind = pf;
    // If URL has ?q=, pre-populate and search
    var params = new URLSearchParams(window.location.search);
    var q = params.get('q') || '';
    if (q) { input.value = q; doSearch(q); }
});

Pagefind is loaded only when someone navigates to the search page. Every other page has zero Pagefind overhead. If the URL already has ?q=security, the search runs immediately without waiting for user input.

Debounced search:

var timer;
input.addEventListener('input', function () {
    clearTimeout(timer);
    var q = input.value.trim();
    timer = setTimeout(function () { doSearch(q); }, 250);
});

250ms after the last keystroke. Fast enough to feel instant, slow enough to prevent 10 parallel queries when typing "symfony static site."

Query and results:

function doSearch(query) {
    pagefind.search(query).then(function (search) {
        var top = search.results.slice(0, 10);
        Promise.all(top.map(function (r) { return r.data(); })).then(function (items) {
            render(items, query);
        });
    });
}

Top 10 results. .data() resolves asynchronously — each result requires loading its metadata from a .pf_meta file. Promise.all parallelises the metadata fetches.

Result rendering:

Each result card includes:

  • Title (linked to the page URL)
  • Meta line: category + date separated by
  • Excerpt text — HTML-escaped via esc()
  • Tags as links using the data-tag-base URL template
  • "Read more" link
  • Featured image with responsive srcset built from image_variant_widths

Security tradeoff — no highlighted matches:

Every piece of user and content data passes through esc():

function esc(s) {
    return String(s)
        .replace(/&/g, '&amp;').replace(/</g, '&lt;')
        .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

This strips Pagefind's <mark> highlight tags — </mark> becomes &lt;/mark&gt;. The deliberate tradeoff: no XSS risk via crafted content in search results, at the cost of no visual highlighting of matched terms.

This was a security fix. The original code used innerHTML for excerpts, allowing XSS via malicious content frontmatter. The fix (CHANGELOG.md line 37) switched to esc() + textContent rendering. The security posture is: all search content is escaped. Search highlights are sacrificed.

UI B: Search overlay (Cmd+K anywhere)

File: docs/demo/assets/search-overlay.js (148 lines). Embedded in base.html.twig as a hidden full-screen overlay.

Markup in the base template:

{# base.html.twig — overlay always in DOM, hidden by default #}
<div class="search-overlay" id="site-search"
     aria-hidden="true" role="dialog" aria-modal="true"
     aria-label="{{ 'search.title'|trans }}">
    <div class="search-overlay__panel">
        <div class="search-overlay__header">
            <i class="ph ph-magnifying-glass" aria-hidden="true"></i>
            <input class="search-overlay__input" id="site-search-input"
                   type="search" placeholder="{{ 'search.placeholder'|trans }}"
                   autocomplete="off" spellcheck="false">
            <button class="search-overlay__close"
                    aria-label="{{ 'search.close'|trans }}">
                <i class="ph ph-x" aria-hidden="true"></i><kbd>Esc</kbd>
            </button>
        </div>
        <div class="search-overlay__results" id="search-overlay-results"
             aria-live="polite"></div>
        <div class="search-overlay__footer">
            <span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
            <span><kbd>↵</kbd> Open</span>
            <span><kbd>Esc</kbd> Close</span>
        </div>
    </div>
</div>

The overlay exists on every page. display: none by default, toggled to display: flex by the .is-open class.

Triggers:

  • Click on any .search-trigger button (magnifying glass icon in the nav)
  • Cmd+K or Ctrl+K from anywhere on the page — press again to close

Close mechanisms:

  • Click the close button
  • Click the backdrop (the overlay itself)
  • Press Escape

Lazy loading — on first open, not page load:

function loadPagefind() {
    if (pagefind || isLoading) return;
    isLoading = true;
    import('/pagefind/pagefind.js').then(function (pf) {
        pagefind = pf;
        isLoading = false;
        var q = input.value.trim();
        if (q) doSearch(q);
    }).catch(function () {
        isLoading = false;
    });
}

Someone might browse 10 pages and never open search. Pagefind loads only when they press Cmd+K for the first time. The catch handler prevents isLoading from getting stuck if the import fails.

Results — simpler render:

Top 8 results, title + excerpt only. No images, no tags, no meta line. The excerpt is rendered via innerHTML:

'<span class="search-overlay__result-excerpt">' + r.excerpt + '</span>'

This preserves Pagefind's <mark> highlight tags — unlike the standalone search which escapes them. The overlay trades the XSS surface of crafted content for visible search term highlighting. Title and URL are still escaped via esc().

Keyboard navigation:

Key From input From result
(ArrowDown) Focus first result Move to next result
(ArrowUp) (ignored) Move to previous result, or back to input if on first
Enter (no action) Navigate to the result's URL
Esc Close overlay Close overlay

Focus management: on open, focus moves to the input after 50ms delay (lets the CSS transition start). On close, focus returns to the element that was active before the overlay opened.

Accessibility:

  • aria-hidden="true" / "false" — toggled on open/close
  • role="dialog", aria-modal="true" — screen reader announces it as a modal
  • aria-live="polite" on results — screen reader announces result count changes
  • aria-label on input, close button, results container

Production deployment

Version pinning (the Raspberry Pi 5 war story)

The production deploy script (scripts/rebuild-content.sh) pins Pagefind to @1.5.0:

docker compose run --rm php npx --yes pagefind@1.5.0 \
    --site public/static \
    --output-path public/pagefind

The DDEV-local build script (.ddev/commands/web/build) does not pin:

npx --yes pagefind --site public/static --output-path public/pagefind

Why the split? Later Pagefind versions ship a jemalloc-linked ARM64 binary. This binary crashes on hosts with 16K-page kernels — which includes the Raspberry Pi 5 running a default 64-bit kernel. The error:

<jemalloc>: Unsupported system page size

The pinned version 1.5.0 is the last one without the jemalloc dependency. DDEV-local runs on x86_64, where all versions work — no pin needed. This split means the dev environment always runs the latest Pagefind, while production stays on the known-working version. Tracking upstream: Pagefind#1147.

Index size

~437 files for a 170-page, 2-language site. Each new page adds roughly 3 metadata files (one per language). Size grows linearly, not exponentially. A 1,000-page site with 2 languages generates around 6,000 index files — manageable for any filesystem and nginx try_files.

WASM performance

The WASM binary is per-language (~200–500KB). Queries run in a Web Worker (pagefind-worker.js), off the main thread. Latency: under 50ms for most queries on a 170-page index. The bottleneck is network transfer of the WASM file, not query execution — and the file is cached by the browser after the first load. No server-side query processing at all.

Build time

npx pagefind runs in 2–5 seconds for 170 pages. Dominated by WASM compilation from Rust source (happens once, not per page), not HTML parsing. Scales linearly with page count. For a 10,000-page site, expect around 30–60 seconds — still acceptable for a deploy step.

nginx config

No special configuration. public/pagefind/ is just static files, served by try_files like everything else. The import('/pagefind/pagefind.js') in JS resolves as a normal static file request to /pagefind/pagefind.js. No origin redirects, no CORS headers, no special location blocks.

Customisation options

Change result appearance — place modified copies of search.js or search-overlay.js in local/assets/. The originals are in assets/ and docs/demo/assets/ respectively. The local copy takes priority through the local/ namespace.

Add more metadata — any element with data-pagefind-meta="mykey" becomes result.meta.mykey in JS. Add a data-pagefind-meta="series" attribute to series posts, read it in search.js render, display series badges.

Style the search UI — both UIs use plain CSS classes (.search-page__input, .search-overlay__result, .search-result). Override in local/assets/styles/ like any other component SCSS.

Change the overlay trigger — the overlay is embedded via template override. Copy docs/demo/templates/base.html.twig to local/templates/base.html.twig, customise the trigger button or the keyboard shortcut.

Multi-language search — automatic. Pagefind detects the lang attribute on <html> and loads the matching WASM binary. The search page searches the current locale's index. Switching to a different language navigates to /{locale}/search/, loading that locale's different WASM binary.

What Pagefind cannot do

Honest limitations that matter for real sites:

No fuzzy search. Exact substring matching with stemming per language. Typing "securty" will not find "security." Typing "symphony" will not find "Symfony." This is the biggest UX gap. Google Programmable Search is the fallback for sites that genuinely need fuzzy matching.

No search analytics. No server to log to. No "most searched terms" dashboard. If you need to know what people search for, you need a server-side analytics integration (Cloudflare Analytics, Plausible, etc.) triggered from the search JS.

No real-time indexing. The index is built at deploy time. A new post published via scheduled date appears in search only after the next deploy. For sites that deploy on every content change (the standard notACMS workflow), this is a non-issue. For sites that deploy weekly, search lags behind.

No faceted search. Filters like "only posts from 2026" or "only tutorials" are not search facets — they are separate pages (/blog/archive/2026/, /blog/category/tutorials/). Pagefind searches all content uniformly. Faceted search would require building a custom index structure on top of Pagefind's metadata, or switching to a different search engine.

No cross-locale search. Each locale is a separate index. Searching "security" in English will not find Polish posts tagged bezpieczenstwo. This is a property of Pagefind's language-detection model — it treats each language as an independent corpus. For a two-language blog, this means users occasionally miss content in the other language.


"Search on a static site" sounds like a contradiction. It is not — a build-time WASM index with a lazy-loaded JS client handles it entirely. No Elasticsearch, no Algolia, no PHP search endpoint. Two UIs for two interaction modes. One production war story about ARM64 jemalloc. The cost is ~2–5 seconds at deploy time and ~200–500KB of browser-cached WASM. For 99% of static site use cases, this is all you need. The alternative — Algolia, Elasticsearch, a PHP search endpoint — costs money, adds moving parts, and solves problems most static sites do not have.


This concludes the open-source-notacms series. Part 1 started with 368 tests. Part 2 covered the AI code audit. Part 3 addressed security vulnerabilities. Part 4 designed the override pattern. Part 5 shipped it. Part 6 explained the build pipeline. Part 7 built a production theme. Part 8 covered the search architecture. The original WordPress to Symfony series tells the full migration story.