---
title: Pagefind + Docusaurus integration pitfalls
title_en: Pagefind + Docusaurus integration pitfalls
description: Two non-obvious failures when adding Pagefind search to a Docusaurus site — jiti cannot load the ESM-only pagefind package in plugins, and Docusaurus discards navbar component state via a deferred Layout remount after route transitions.
sidebar_label: Pagefind + Docusaurus pitfalls
---

# Pagefind + Docusaurus integration pitfalls

Pagefind has no official Docusaurus integration (the proposal was [closed as not planned](https://github.com/facebook/docusaurus/issues/10290)), 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:

```js
// 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`:

```json
"build": "docusaurus build && node scripts/build-search-index.mjs"
```

```js
// 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:

```jsx
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 <kbd>Ctrl</kbd>+<kbd>K</kbd> 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`:

```jsx
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:

  ```js
  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](https://pagefind.app/docs/node-api/)
- [Pagefind CLI/config options](https://pagefind.app/docs/config-options/) (`rootSelector`, `excludeSelectors`, `forceLanguage`)
- [docusaurus#10290 — Pagefind plugin proposal, closed as not planned](https://github.com/facebook/docusaurus/issues/10290)
- [jiti](https://github.com/unjs/jiti) — the loader Docusaurus uses for config/plugin modules
- [React `useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)
