Headless Search in Astro with Pagefind

Headless Search in Astro with Pagefind

2026-01-15T00:00:00.000Z

Pagefind is a fantastic tool for adding full-text search to a static or pre-rendered website. While its default UI (pagefind-ui.js) is a great way to get started, you might want more control over the look and feel of the search results to ensure they perfectly match your site’s design.

This is where a “headless” approach comes in. By using Pagefind’s core API (pagefind.js), we can use it purely as a search engine, retrieve the raw data, and then render the results using our own custom components and styling. This article walks through the exact implementation used on this site.

The Challenge: SSR and a Static Indexer

A key challenge is that this site is configured for server-side rendering (output: 'server'), but Pagefind is designed to index a folder of static HTML files after a build. The solution is Astro’s powerful hybrid rendering capability. We pre-render the content-heavy pages (like blog posts and projects) so that Pagefind has static HTML to work with.

The entire process is kicked off by a simple postbuild script in package.json:

"scripts": {
  "dev": "astro dev",
  "start": "astro start",
  "build": "astro build",
  "postbuild": "pagefind --site dist/client"
}

Step 1: Controlling What Gets Indexed

By default, Pagefind indexes the entire <body> of every HTML file it finds. This is not ideal, as it would include list pages (like /blog or /tags) in the search results.

The most elegant solution is the data-pagefind-body attribute. Once Pagefind sees this attribute on any page, it completely changes its strategy: it will only index pages that have this attribute, and on those pages, it will only index the content within that element.

Since all our blog posts and projects use a shared ContentLayout.astro, we only needed to add the attribute to the root <article> tag in that file:

// src/layouts/ContentLayout.astro
<article data-pagefind-body>
    ...
</article>

This single change ensures that only true content pages are ever included in the search index.

Step 2: The Search Page and Headless Script

Our search page (src/pages/search.astro) is a pre-rendered Astro page that contains a standard search form and a container for the results. The real magic happens in the client-side script.

First, we import the core Pagefind library, not the UI version:

<script is:inline src="/pagefind/pagefind.js"></script>

Why No Astro Component for Search Results?

It’s important to understand a key architectural difference here: Astro components (.astro files) are rendered server-side, at build time. This means that once the page is delivered to the browser, there’s no way for a client-side JavaScript script (like our Pagefind handler) to dynamically render a new Astro component.

Since Pagefind provides its results client-side after the page has loaded, we cannot use an Astro component for each individual search result. Instead, we create a JavaScript helper function (createResultHTML) that manually constructs HTML strings. This function is carefully designed to mimic the exact structure and styling of a dedicated Astro component (we initially created a SearchItem.astro for reference, but it was later removed as it wasn’t directly used in the rendering).

Here is a breakdown of the complete script:

// from src/pages/search.astro
const searchForm = document.querySelector("#search-form");
const searchInput = document.querySelector("#search-input");
const resultsContainer = document.querySelector("#search-results");
const messageContainer = document.querySelector("#search-message");
let pagefind;

// A helper function to generate HTML that matches our site's component style
function createResultHTML(result) {
    return `
        <li>
            <a href="${result.url}" class="list-item-link">
                <h2 class="title">${result.meta.title}</h2>
                <p class="summary">${result.excerpt}</p>
            </a>
        </li>
    `;
}

// The main function that performs the search
const runSearch = async (searchTerm) => {
    // Lazily import and initialize Pagefind on the first run
    if (!pagefind) {
        try {
            pagefind = await import("/pagefind/pagefind.js");
            // Set a shorter excerpt for the summary
            await pagefind.options({ excerptLength: 15 });
        } catch (e) {
            console.error("Pagefind failed to load:", e);
            messageContainer.innerText = "Search is currently unavailable.";
            return;
        }
    }

    resultsContainer.innerHTML = "";
    messageContainer.innerText = "";
    
    // Don't search for very short terms
    if (searchTerm.length < 3) {
        if(document.activeElement === searchInput) {
             messageContainer.innerText = "Please enter at least 3 characters.";
        }
        return;
    }

    // Perform the search
    const search = await pagefind.search(searchTerm);

    if (search.results.length === 0) {
        messageContainer.innerText = `No results found for "${searchTerm}".`;
    } else {
        messageContainer.innerText = `${search.results.length} results found for "${searchTerm}".`;
    }

    // Get the rich data for each result
    const resultItems = await Promise.all(
        search.results.map(async r => {
            const data = await r.data();
            // Note: Pagefind automatically highlights terms in the title and excerpt
            return createResultHTML(data);
        })
    );
    
    // Render the results
    resultsContainer.innerHTML = resultItems.join("");
};

// Add event listeners to run the search on submit or as the user types
searchForm.addEventListener("submit", (e) => e.preventDefault());
searchInput.addEventListener("input", (e) => runSearch(e.target.value.trim()));

// Run an initial search if the page was loaded with a query parameter
const initialQuery = new URL(window.location.href).searchParams.get("query") || "";
if (initialQuery) {
    searchInput.value = initialQuery;
    runSearch(initialQuery);
}

Conclusion

By switching from the pre-built UI to the core Pagefind API, we gained complete control over our search experience. This “headless” approach allows us to use Pagefind as a powerful, data-only search engine while we handle the rendering with our own styles and component structures. The result is a fast, effective, and perfectly on-brand search feature that is seamlessly integrated into the rest of the site.

HQ Key Mappings

gg / G Jump to top/bottom of page
j / k Scroll page up/down
h / l Focus navigation bar and move left/right
c / Escape Remove focus from nav bar / Close modal
x / Enter Follow a focused navigation link
t / T Cycle through site themes
m / M Toggle light/dark mode
f / / Open search dialog
? Open help dialog