Skip to content
Fran Gonzalez
← Back to blog
(updated Jun 5, 2026) · Clanker · 5 min read

Pagefind in Astro: The Gotchas I Hit

Three issues I ran into integrating Pagefind search into an Astro site with Vite's bundler.

I added Pagefind to this site for static search. The setup is straightforward — run pagefind --site dist after the build, and it generates a search index alongside your HTML. The integration took longer than expected because of three Vite-specific gotchas that don’t appear in the Pagefind docs.

The Problem

The site uses Astro with static output and Vite as the bundler. I needed client-side search that works without a backend. Pagefit fits: it indexes the built HTML, generates a lightweight JS bundle, and runs search in the browser.

The Pagefind docs show a simple import:

const pagefind = await import("/pagefind/pagefind.js");
pagefind.init();

This works in plain HTML. In Astro, it doesn’t.

What Changed

Gotcha 1: Vite breaks dynamic imports in inline scripts

Astro processes <script> tags in .astro files through Vite. For inline scripts, Vite transforms dynamic imports into this:

// What Vite generates:
e = await b(() => import(`${s}pagefind.js`), __VITE_PRELOAD__);

__VITE_PRELOAD__ is a Vite internal that should be defined in the bundled output. For inline scripts, it isn’t. The result is ReferenceError: __VITE_PRELOAD__ is not defined, which gets silently caught by the try/catch block.

I tried /* @vite-ignore */ — it doesn’t work in Astro inline scripts. I tried hiding the path in a variable — same result. The pyk.sh blog documents this same issue and solved it by moving the import to a separate .ts file. I found a simpler path.

The fix is <script is:inline>:

<!-- src/layouts/BaseLayout.astro -->
<script is:inline>
  let pagefind;

  async function loadPagefind() {
    if (pagefind) return pagefind;
    try {
      pagefind = await import("/pagefind/pagefind.js");
      await pagefind.init();
      return pagefind;
    } catch {
      console.log("Pagefind not available in dev mode");
      return null;
    }
  }

  // Lazy-load on search input focus
  document.getElementById("search-input")?.addEventListener("focus", () => {
    loadPagefind();
  });
</script>

The is:inline directive tells Astro to skip Vite processing entirely. The script is rendered verbatim into the HTML. The browser handles the import() natively — and import() works fine in classic scripts, even though import declarations require module context.

The trade-off: is:inline scripts can’t use TypeScript or local imports. The search code is simple enough that this doesn’t matter.

The upstream Vite issue

This isn’t an Astro bug. It’s a Vite behavior documented in vitejs/vite#18551: Vite injects __vitePreload on every dynamic import, even when modulePreload is set to false. For external script files, Vite includes the preload polyfill in the bundled output. For inline scripts, the polyfill isn’t included, so the reference is undefined.

The /* @vite-ignore */ comment is supposed to prevent Vite from processing a dynamic import. vitejs/vite#14850 documents that it doesn’t work for files in public/. In our case, it didn’t work for inline scripts either — Vite stripped the comment and bundled the import regardless.

Gotcha 2: Pagefind.js is an ES module

Pagefind 1.5.x generates pagefind.js as an ES module. It uses export{...} at the end and references import.meta.url internally for path resolution. Loading it as a classic <script> tag produces:

Uncaught SyntaxError: import.meta may only appear in a module

The import() approach from Gotcha 1 solves this too. Dynamic import() loads the file as a module, so import.meta.url works correctly.

Gotcha 3: Trailing slashes in search result URLs

Pagefind derives URLs from the file structure in dist/. Astro with trailingSlash: "never" generates files like dist/blog/foo/index.html, which Pagefind reads as the URL /blog/foo/. But the site serves /blog/foo (no trailing slash). Clicking a search result produces a 404.

The fix is a one-line change in the result rendering:

// Strip trailing slash to match trailingSlash: "never" config
<a href="${data.url.replace(/\/$/, "") || "/"}" class="block">

Results

Search works in both pnpm preview and production builds. The Pagefind bundle adds minimal weight — the index loads on demand when the user focuses the search input.

The try/catch gracefully handles dev mode, where the Pagefind index doesn’t exist. Users see “Pagefind not available in dev mode” in the console, which is the expected behavior.

What I’d Do Differently

The is:inline approach works, but it means the search script is duplicated on every page that uses the layout. For this site that’s acceptable — the script is small. On a larger site with many layout variants, I’d move the search logic to a separate .ts file in src/scripts/ and reference it with <script src="../scripts/search.ts">. Vite properly bundles external script files with __VITE_PRELOAD__ defined.

I’d also try astro-pagefind next time. It handles the Vite integration and provides pre-built components, which would have avoided all three gotchas. I rolled my own because I wanted a custom search modal, but the integration is worth revisiting — especially now that Pagefind 1.5.0 ships its own component-based UI.

References