5 Min. read

Adding Pagefind Search to my Astro Website

At the beginning of every year I spend some time updating my website. I can’t help it. It’s become some sort of ritual. Last year I moved away from my custom Gulp-based monstrosity to Astro as a static site generator. This year, in addition to a slight redesign, I wanted to make the site searchable.

My new year resolutions include blogging more and releasing a new Unreal Engine plugin (wish me luck!). This means adding many new pages to my website, which is now made of ~500 assets including pages, images, etc. — twice as many as last January! Being able to search for specific content might become useful.

Pagefind

Since the time I can dedicate to my website is limited — and my web dev skills aren’t as sharp as they used to be — I needed a solution that would mostly work out-of-the-box.

Pagefind logo

A Google search revealed several alternatives for implementing search in static websites such as Lunr or Typesense, but Pagefind stood out for me:

  • It promises almost zero config. Pagefind runs after the site has been built and indexes the files in the built output folder automatically.
  • It provides a fully-functional search UI by default.
  • It’s the same solution used by Starlight — Astro’s docs — so I had an example to look at.
  • It’s all JS and self-hosted. I don’t want to integrate remote APIs if I can avoid it.

Pagefind can be installed from npm:

npm i pagefind

Creating the search page

The following is my Astro search page. The code is more or less a copy-paste of the code available in the Pagefind docs.

---
import MainLayout from "@layouts/MainLayout.astro";
---
 
<MainLayout title="Search">
    <slot slot="head">
        {/* 1. Include the Pagefind stylesheet */}
        <link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
    </slot>
    <slot>
        {/* 2. Define where the UI should be created */}
        <div id="search"></div>
 
        {/* 3. Include the Pagefind script */}
        <script is:inline src="/pagefind/pagefind-ui.js" defer="true"></script>
 
        {/* 4. Initialize things */}
        <script is:inline>
            window.addEventListener("DOMContentLoaded", function()
            {
                new PagefindUI({ element: "#search", resetStyles: false });
            });
        </script>
    </slot>
</MainLayout>
 
{/* 5. Customize the look */}
<style>
:root {
    --pagefind-ui-scale: 1.125;
    --pagefind-ui-primary: var(--link-color);
    --pagefind-ui-text: var(--text-color);
    --pagefind-ui-background: var(--background-color-darker);
    --pagefind-ui-border: var(--background-color-brighter);
    --pagefind-ui-tag: var(--background-color-brighter);
    --pagefind-ui-border-width: 3px;
    --pagefind-ui-border-radius: 5px;
    --pagefind-ui-image-border-radius: 5x;
    --pagefind-ui-image-box-ratio: 3 / 2;
    --pagefind-ui-font: var(--font-family);
}
</style>

Indexing content

Following the same approach used by Astro in their docs, I’ve created a custom Astro integration that’s responsible for running Pagefind at the end of the build.

Pagefind can be run from the command line or programmatically. Starlight it’s using the former:

astro.config.ts
import type { AstroIntegration } from "astro";
import * as pagefind from "pagefind";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import path from "node:path";
 
function pagefindIntegration(): AstroIntegration
{
    return {
        name: "pagefind",
        hooks: {
            "astro:build:done": async ({ dir }) =>
            {
                const targetDir = fileURLToPath(dir);
                const cwd = path.dirname(fileURLToPath(import.meta.url));
                const relativeDir = path.relative(cwd, targetDir);
                await new Promise<void>((resolve) =>
                {
                    spawn("npx", ["-y", "pagefind", "--site", relativeDir], {
                        stdio: "inherit",
                        shell: true,
                        cwd,
                    }).on("close", () => resolve());
                });
            },
        }
    };
}
 
export default defineConfig({
    // ...
    integrations: [pagefindIntegration()],
});

In my case I went with the programmatic approach because I wanted to index only specific parts of the website (namely posts and tools) and doing so programmatically seemed easier — not sure if it ultimately was though. 😄

astro.config.ts
function pagefindIntegration(): AstroIntegration
{
    return {
        name: "pagefind",
        hooks: {
            "astro:build:done": async ({ dir }) =>
            {
                const outDir = fileURLToPath(dir);
 
                // Create the index
                const createResult = await pagefind.createIndex({ verbose: true });
 
                const index = createResult.index;
                if (!index || createResult.errors.length)
                {
                    console.error(createResult.errors.join("\n"));
                    return;
                }
 
                // Parse the content
                const indexResult = await index.addDirectory({
                    path: outDir,
                    glob: "my-folder/**/*.{html}",
                });
 
                console.log(`Indexed ${indexResult.page_count} pages`);
                if (indexResult.errors.length)
                {
                    console.error(indexResult.errors.join("\n"));
                }
 
                // Save the files
                const writeResult = await index.writeFiles({
                    outputPath: path.join(outDir, "pagefind"),
                });
 
                if (writeResult.errors.length)
                {
                    console.error(writeResult.errors.join("\n"));
                }
 
                console.log("Pagefind complete");
            },
        },
    };
}

In both cases, after running npx astro build, a new pagefind folder will be created in the built output folder, containing all the required files.

Testing

And that’s all it takes to get a working search input field and display the search results. Running npx astro preview allows to preview things:

Improving indexed data

While Pagefind does a nice job by default at indexing content, I found that spending some time configuring what content is indexed can drastically improve the search results.

For my website I’ve marked the main content with the data-pagefind-body tag and ignored some items (like breadcrumbs) by tagging them with data-pagefind-ignore.

<main data-pagefind-body data-pagefind-meta={`title:${pagefind.title}`}>
    <nav class="breadcrumbs" data-pagefind-ignore>
        <!-- ... -->
    </nav>
</main>

In some instances, I wanted to display a different title in the search results than the one specified in the <title> tag, which can be achieved with the data-pagefind-meta attribute.

Final thoughts

I’m really happy about how website search turned out — especially considering the low effort required to get it working! I only have two minor complaints:

  • Search can’t be testing during development — or at least I wasn’t able to find a way to do so. Having to build and preview slows things down a bit (although it’s still really fast compared to, say, compile any Unreal Engine project).
  • I wasn’t able to find a way to let Astro bundle the Pagefind CSS and JS files. In my case that’s probably even better since the search box exists in its own separate page, but it might have been useful if the search box was visible in every page.