Pagefind is a search library for static websites. It works by indexing the built static files of our site, and adding a JavaScript search API that allows the user to find content with code that runs client-side.

NOTE

Pagefind can be used with Hugo, Eleventy, Jekyll, Next, Astro, SvelteKit, or any other SSG. There’s even an Astro integration, but we’ll take care of it ourselves, from scratch (it’s more fun and educative).

Installing Pagefind

Pagefind is a static binary, written in Rust with no dynamic dependencies, so installing it would be as simple as:

  1. Downloading the release for our platform (currently supported on Windows, macOS, and Linux distributions).
  2. Place it somewhere in our PATH
  3. And voila!

TIP

The official Pagefind site contains excellent and comprehensive instructions for installing Pagefind

Since we are in Node.js, we can take advantage of the wrapper package for this runtime, and run:

npx pagefind --site "public"

Where public is whatever folder your SSG outputs the generated site, but I’m gonna go ahead and add it to my project dependencies so that I can integrate it in my package.json scripts:

npm i pagefind

Building the Index

As we’ve mentioned at the beginning, Pagefind indexes your site after it builds, so let’s build it:

npm run build

That should output our site’s build to a folder named dist. Now let’s use the pagefind command to index that folder:

pagefind --site dist

That will generate a folder named pagefind under our dist folder (so in dist/pagefind) that contains:

TIP

Probably a good idea to add a pagefind entry to your .gitignore; I added also another one for the dist folder.

Updating our build script

So far we need to use several commands to get Pagefind generating an index of the latest version our our site:

  1. Build the site itself: npm run build
  2. Generating the Pagefind index: pagefind --site dist

It’s probably a good idea to combine these two commands, so that when we deploy our site, whatever pipeline runs the npm run build, will also generate the Pagefind stuff. So let’s update the build script in our package.json file:

"scripts": {
"build": "astro build && pagefind --site dist"
}

Running npm run build now, would produce the usual output, plus the following:

[...]
dist/pagefind
[Building search indexes]
Total:
Indexed 1 language
Indexed 45 pages
Indexed 1285 words
Indexed 0 filters
Indexed 0 sorts

If we open the dist folder (where Astro outputs the build), we should see a pagefind folder with our indexed site.

CAUTION

In my case, that didn’t work due to the way my github workflow was written:

- name: Build with Astro
run: |
${{ steps.detect-package-manager.outputs.runner }} astro build \
--site "${{ steps.pages.outputs.origin }}" \
--base "${{ steps.pages.outputs.base_path }}"
working-directory: ${{ env.BUILD_PATH }}

As you can see, it runs the astro command directly, and not our npm run build. So if you don’t want to mess with your pipeline (no biggie, but hey), you can add the following to your build script:

"scripts": {
"pagefind": "pagefind --site dist && cp -r dist/pagefind public",
"build": "astro build && npm run pagefind"
}

That way, after every build, we end up with the pagefind folder in our public directory, and our GitHub pages deployment won’t have trouble finding it.

Bottom line, you can do it your way, but the whole setup needs the pagefind folder available in public folder.

IMPORTANT

Don’t forget to remove pagefind from your .gitignore file.

Adding the Component UI

Assuming that everytime we build our site, Pagefind indexes its content, we need a UI to search and see the results. Pagefind now recommends the Component UI, which gives us declarative web components for common search interfaces.

NOTE

The previous UI, based on pagefind-ui.js and new PagefindUI(...), still exists in the docs, but the Component UI is the recommended path now.

The nice thing about the new system is that the components follow WAI-ARIA best practices, handle visible and assistive text, and are designed to be hard to misuse.

Loading the Component UI

For the Component UI, we load these files:

<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-component-ui.js" type="module"></script>

In Astro, I added them to my main layout and used import.meta.env.BASE_URL so the paths still work if the site ever lives under a subpath:

---
const pagefindPath = `${import.meta.env.BASE_URL}pagefind/`
---
<link href={`${pagefindPath}pagefind-component-ui.css`} rel="stylesheet" />
<script src={`${pagefindPath}pagefind-component-ui.js`} type="module"></script>

Selecting the Component

After loading the Component UI script and stylesheet, we can choose between a couple of components:

For my site, the modal is the best fit:

<pagefind-modal-trigger></pagefind-modal-trigger>
<pagefind-modal></pagefind-modal>

With the modal, Pagefind handles the dialog, focus management, keyboard navigation, and the Cmd+K / Ctrl+K shortcut for us.

Replacing the Custom Search Modal

Before, I had my own search button, my own overlay, and some JavaScript to open and close it. With the Component UI, the markup is enough:

---
const pagefindPath = `${import.meta.env.BASE_URL}pagefind/`
---
<div class="pagefind-search" data-pf-theme="dark">
<pagefind-config bundle-path={pagefindPath}></pagefind-config>
<pagefind-modal-trigger placeholder="Search"></pagefind-modal-trigger>
<pagefind-modal reset-on-close>
<pagefind-modal-header>
<pagefind-input></pagefind-input>
</pagefind-modal-header>
<pagefind-modal-body>
<pagefind-summary></pagefind-summary>
<pagefind-results show-images></pagefind-results>
</pagefind-modal-body>
<pagefind-modal-footer>
<pagefind-keyboard-hints></pagefind-keyboard-hints>
</pagefind-modal-footer>
</pagefind-modal>
</div>

The minimal version would be even smaller:

<pagefind-modal-trigger></pagefind-modal-trigger>
<pagefind-modal></pagefind-modal>

I used the longer version because I wanted to keep result images enabled:

<pagefind-results show-images></pagefind-results>

That’s the Component UI way to enable images in search results.

The pagefind-config component tells the UI where the generated Pagefind files live:

<pagefind-config bundle-path="/pagefind/"></pagefind-config>

In my Astro component, that value comes from BASE_URL:

<pagefind-config bundle-path={pagefindPath}></pagefind-config>

Keeping the Content Indexed

The UI is separate from the indexing markup. My post and project pages still mark their main content with data-pagefind-body:

<div class="post-content" data-pagefind-body>
<Content />
</div>

That tells Pagefind which part of the page should be searchable.

Result Images

For images in search results, I provide explicit Pagefind metadata in the page head:

<meta
data-pagefind-meta="image[content]"
content={post.data.coverImage.cropped.src}
/>
<meta
data-pagefind-meta="image_alt[content]"
content={post.data.coverImage.alt}
/>

One thing to keep in mind is that Pagefind indexes the built site. If images look broken while running the dev server, test with the production build instead:

Terminal window
npm run build
npm run preview

Fixing Result Titles

My search results showed the site title instead of the post title. The reason was subtle: Pagefind’s automatic title metadata comes from the first h1 on the page.

My logo used to be an h1:

<h1 class="anaglyph">生活のバランス</h1>

So Pagefind treated the logo as the page title. The fix was to make the logo a non-heading element:

<span class="anaglyph">生活のバランス</span>

Then I restored the old visual size with CSS:

.anaglyph {
display: inline-block;
font-size: 2em;
font-weight: 900;
line-height: 1.2;
}

Another good option is to explicitly mark the real page title for Pagefind:

<h1 data-pagefind-meta="title">{post.data.title}</h1>

That tells Pagefind exactly what title to use in search results.

Theming the New UI

The Component UI uses CSS custom properties. I scoped them to my search wrapper:

.pagefind-search {
--pf-text: var(--col-text-1);
--pf-text-secondary: var(--col-text-2);
--pf-text-muted: var(--col-text-2);
--pf-background: var(--col-bg);
--pf-border: var(--col-text-3);
--pf-border-focus: var(--col-text-2);
--pf-hover: var(--col-overlay);
--pf-outline-focus: var(--col-accent-1);
--pf-font: 'Mona Sans', sans-serif;
--pf-input-height: 2rem;
--pf-input-font-size: 16px;
--pf-border-radius: 0.3rem;
--pf-modal-backdrop: var(--col-overlay);
--pf-modal-max-width: min(800px, calc(100vw - 2rem));
--pf-modal-max-height: min(80dvh, 800px);
--pf-modal-top: 5rem;
}

This made the Pagefind modal fit the rest of the site’s dark theme.

Fixing Astro ClientRouter Navigation

One issue I ran into was related to Astro’s client-side navigation. The modal worked the first time, but after clicking a search result and navigating to another page, Cmd+K started behaving erratically.

The browser complained with something like:

Failed to execute 'showModal' on 'HTMLDialogElement':
The element is not in a Document.

The problem was that Astro swapped the page DOM, but Pagefind still had references to the old modal internally. So the next time I opened search, Pagefind sometimes tried to open a modal that was no longer in the document.

The fix was to clean up Pagefind before Astro swaps pages:

<script>
if (!document.documentElement.dataset.pagefindCleanupBound) {
document.documentElement.dataset.pagefindCleanupBound = 'true'
document.addEventListener('astro:before-swap', () => {
document.querySelectorAll('pagefind-modal').forEach((modal) => {
if ('close' in modal && typeof modal.close === 'function') {
modal.close()
}
})
const manager = window.PagefindComponents?.getInstanceManager?.()
if (!manager?.getInstanceNames || !manager?.removeInstance) return
for (const name of manager.getInstanceNames()) {
manager.removeInstance(name)
}
})
}
</script>

That closes any open Pagefind modal and clears Pagefind’s cached component instances before Astro swaps the page. The next page then creates fresh modal and trigger references.

Final Check

After adding the UI, rebuild the site:

Terminal window
npm run build

Pagefind should scan the built HTML, find the data-pagefind-body elements, and generate the index. After that, the search trigger should open the modal, Cmd+K / Ctrl+K should work, and the results should show the correct post titles.