Skip to main content

Pagefind + Docusaurus integration pitfalls

Pagefind has no official Docusaurus integration (the proposal was closed as not planned), so wiring it up means a custom indexing step plus a custom src/theme/SearchBar. Both halves hide a trap. This article documents the two failures hit while building search for this site, in the order they appeared.

Environment

  • Docusaurus 3.10.0 (classic preset), React 18.3
  • pagefind 1.5.2 (npm package, platform binary via optionalDependencies)
  • Node v24.6.0, Windows 11 local / Cloudflare Pages (Linux) CI

Pitfall 1 — a Docusaurus plugin cannot import the ESM-only pagefind package

Problem

The natural design is a local plugin with a postBuild hook:

// plugins/pagefind.mjs
import { createIndex } from "pagefind";

export default function pagefindPlugin() {
return {
name: "docusaurus-plugin-pagefind",
async postBuild({ outDir }) { /* createIndex, addDirectory, writeFiles */ },
};
}

docusaurus build fails immediately:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined
in node_modules\pagefind\package.json

Moving to a dynamic import inside the hook (const { createIndex } = await import("pagefind")) fails with the same error. Hiding the import from the transpiler with new Function("m", "return import(m)") fails differently:

TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]: A dynamic import callback was not specified.

Root Cause

Docusaurus loads docusaurus.config.js and every local plugin module through jiti, a CJS-based runtime loader. Three layers block ESM-only packages like pagefind (whose exports map only defines import conditions):

  1. Static import statements are transpiled to require() → the exports map rejects CJS resolution.
  2. await import(...) is also transpiled to jiti's own require — dynamic syntax does not survive.
  3. Even a hidden native import() runs inside jiti's vm context, which has no dynamic import callback registered, so Node refuses it at the VM level.

There is no way to consume an ESM-only package from jiti-loaded code; the plugin approach is a dead end, not a syntax problem.

Solution

Run the indexing as a standalone Node script (native ESM, no jiti) chained after docusaurus build:

"build": "docusaurus build && node scripts/build-search-index.mjs"
// scripts/build-search-index.mjs
import { createIndex } from "pagefind";
import path from "node:path";
import { fileURLToPath } from "node:url";

const buildDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "build");

const { index } = await createIndex({
rootSelector: "article", // doc pages only — homepage/404 have no <article>
excludeSelectors: [".theme-doc-toc-mobile", ".theme-doc-footer"],
forceLanguage: "en",
});
const { errors: addErrors, page_count } = await index.addDirectory({ path: buildDir });
if (addErrors.length > 0) { console.error(addErrors.join("; ")); process.exit(1); }
const { errors: writeErrors } = await index.writeFiles({
outputPath: path.join(buildDir, "pagefind"),
});
if (writeErrors.length > 0) { console.error(writeErrors.join("; ")); process.exit(1); }
console.log(`indexed ${page_count} pages → build/pagefind`);

CI needs no changes — the deploy build command is still npm run build. The npm pagefind package installs the platform binary via optionalDependencies, and package-lock.json records all platforms, so a lockfile generated on Windows still installs @pagefind/linux-x64 on a Linux build image.

Pitfall 2 — navbar component state is silently discarded after route transitions

Problem

The custom src/theme/SearchBar held the modal flag in plain React state:

const [isOpen, setIsOpen] = useState(false);
// global Ctrl+K listener calls setIsOpen(open => !open)

Symptom, reproduced with Playwright: open search on the homepage, navigate to a doc page via a result, press Ctrl+K again → nothing happens. The second press works. Clicking the navbar button works. No console error, no page error — the first open attempt after the navigation just vanishes.

Root Cause

Instrumenting the component (instance counter + mount/unmount logs) and wrapping Layout/Navbar with logging wrappers showed the sequence on the failing keypress:

[SearchBar] hotkey fired
[SearchBar] render #1 isOpen=true ← state update applied...
[SearchBar] render #2 isOpen=false ← ...new instance rendered fresh
[Layout] UNMOUNT
[Navbar] UNMOUNT
[SearchBar] UNMOUNT #1
[SearchBar] MOUNT #2
[Layout] MOUNT

The navbar lives inside the per-route Layout. After certain route transitions (here: a src/pages page → a docs-plugin page), Docusaurus/React defers part of the tree reconciliation; the first setState anywhere in the navbar after such a transition triggers a full Layout unmount/remount, and the state update that triggered it is discarded along with the old instance. The document-level keydown listener is re-attached by the new instance, so the next press works — which makes the bug look random.

useState in any navbar-level theme component is therefore unreliable for state that must survive the instant it is set.

Solution

Hold the flag outside React in a module-level store and subscribe with useSyncExternalStore. The remounted instance reads the current value back instead of starting from false:

const searchStore = {
open: false,
listeners: new Set(),
setOpen(value) {
this.open = value;
this.listeners.forEach((l) => l());
},
subscribe(listener) {
searchStore.listeners.add(listener);
return () => searchStore.listeners.delete(listener);
},
};

export default function SearchBar() {
const isOpen = useSyncExternalStore(
searchStore.subscribe,
() => searchStore.open,
() => false, // SSR snapshot
);
// hotkey handler: searchStore.setOpen(!searchStore.open)
// ...
}

Transient state (query text, results, selected index) can stay in useState — losing it across a remount is harmless because the remount coincides with opening an empty modal.

Two smaller traps in the same component, for completeness:

  • Load the runtime engine with the import hidden from webpack — /pagefind/pagefind.js does not exist at compile time:

    const dynamicImport = new Function('u', 'return import(u)');
    pagefindPromise = dynamicImport(`${bundleUrl}pagefind.js`);

    (/* webpackIgnore: true */ also works but still emits a "Critical dependency" warning in the server build.)

  • The import throws under npm start (no index in dev) — catch it and show a fallback message instead of a broken modal.

References