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):
- Static
importstatements are transpiled torequire()→ theexportsmap rejects CJS resolution. await import(...)is also transpiled to jiti's own require — dynamic syntax does not survive.- Even a hidden native
import()runs inside jiti'svmcontext, 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.jsdoes 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
- Pagefind Node indexing API
- Pagefind CLI/config options (
rootSelector,excludeSelectors,forceLanguage) - docusaurus#10290 — Pagefind plugin proposal, closed as not planned
- jiti — the loader Docusaurus uses for config/plugin modules
- React
useSyncExternalStore