Type something to search...
How to add search to a static Astro site?

How to add search to a static Astro site?

To add search to a static Astro site, the simplest reliable option is Pagefind: build your site as usual, run npx pagefind --site dist to index the generated HTML, then add Pagefind's search components to a page. Pagefind writes a compact index and its UI files into dist/pagefind/, and the browser downloads only the small chunks of the index it needs for each query. There's no server, no external service and no API key, so it works on any static host. You can control what gets indexed with data-pagefind-* attributes and automate the indexing step in your build script or in a small Astro integration.

Search is one of the few features a static site seems to need a backend for. Pagefind avoids that by doing the work at build time. In this article you'll learn how static search works, how to set up Pagefind with Astro step by step, how to use the new Component UI that arrived in Pagefind 1.5, how to choose which content is indexed, how to add filters, how to call the JavaScript API directly, how to run indexing from an integration, and when a different approach makes more sense.

How Static Search Works

A server-side search engine receives each query and searches a database. A static site has no server to do that, so the work splits in two:

  1. At build time, a tool reads your built HTML and writes a search index as static files.
  2. In the browser, a script loads the index and runs queries locally.

The challenge is size. Loading a full index for a large site would be slow, so Pagefind splits it into chunks and fetches only what a query needs. For a typical blog, a search downloads tens of kilobytes rather than the whole site's text.

Here's how the common options compare:

ApproachIndex built fromScales to large sitesSetup effortCost
PagefindBuilt HTMLYes, index is chunkedLowFree
JSON index + Fuse.js or similarData you exportOnly small to mediumMediumFree
Hosted search (Algolia and others)Records you pushYesMedium to highUsually paid beyond a free tier

For most Astro content sites, Pagefind is the best default, and it's what Astro's own documentation theme, Starlight, uses.

Step 1: Install Pagefind and Index Your Build

Install Pagefind as a development dependency so its version is pinned with the project:

npm install -D pagefind

Then run it after astro build by updating the build script in package.json:

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

--site points at the folder Pagefind should index, which is Astro's default outDir. Running npm run build on a small test site produced this after Astro's own output:

Running Pagefind v1.5.2 (Extended)
Source:       "dist"
Output:       "dist/pagefind"
[Walking source directory]
Found 4 files matching **/*.{html}
[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.
[Reading languages]
Discovered 1 language: en
[Building search indexes]
Total:
  Indexed 1 language
  Indexed 3 pages
  Indexed 22 words
  Indexed 1 filter
  Indexed 0 sorts
Finished in 0.007 seconds

The dist/pagefind/ folder now contains the index chunks, a WebAssembly search engine and the UI files, including pagefind-component-ui.js and pagefind-component-ui.css.

Because the index is generated from the build, it doesn't exist while you run astro dev. To try search locally, run npm run build and then npm run preview.

Step 2: Add a Search Page with the Component UI

Pagefind 1.5 introduced the Component UI, a set of web components that replaces the older Default UI (pagefind-ui.js and new PagefindUI()). Many tutorials still show the old API, so watch for that. The new components load from the generated bundle:

---
// src/pages/search.astro
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Search</title>
    <link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
    <script is:inline src="/pagefind/pagefind-component-ui.js" type="module"></script>
  </head>
  <body>
    <h1>Search</h1>
    <pagefind-input placeholder="Search the blog"></pagefind-input>
    <pagefind-filter-dropdown filter="tag" label="Tag"></pagefind-filter-dropdown>
    <pagefind-summary></pagefind-summary>
    <pagefind-results></pagefind-results>
  </body>
</html>

The is:inline directive tells Astro to output the tag exactly as written instead of processing and bundling it. That's what you want here, because /pagefind/pagefind-component-ui.js doesn't exist until Pagefind runs after the build. Strictly speaking, Astro already skips processing for any <script> with an attribute other than src, so the type="module" attribute alone has the same effect, but is:inline makes the intent obvious to anyone reading the code. The post on interactivity without a framework explains how Astro treats <script> tags by default.

The components connect to each other automatically when they're on the same page. To check the behaviour, I loaded the built search page in headless Chrome, typed "schema" into the input and read the rendered components:

summary: "1 result for schema"
results: "/blog/collections/ | Content collections"

Choosing a Layout

The Component UI offers three levels of control:

  • Modal: <pagefind-modal-trigger> plus <pagefind-modal> gives you a search button and a full overlay dialog, opened with Cmd+K or Ctrl+K. It's the easiest option for a site header.
  • Searchbox: <pagefind-searchbox> is a compact input with dropdown results, good for embedding in navigation.
  • Building blocks: <pagefind-input>, <pagefind-results>, <pagefind-summary>, <pagefind-filter-dropdown> and <pagefind-filter-pane> for a dedicated search page like the one above.

A site-wide modal is just two elements in your layout:

---
// src/components/SiteSearch.astro
---
<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
<script is:inline src="/pagefind/pagefind-component-ui.js" type="module"></script>

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

Styling uses CSS custom properties prefixed with --pf-, and setting data-pf-theme="dark" on a wrapping element switches to the dark palette. Pagefind doesn't follow prefers-color-scheme on its own, so connect it to your site's theme if you have dark mode.

Step 3: Control What Gets Indexed

By default Pagefind indexes everything inside <body>, which includes headers, navigation and footers. Repeated text like that pollutes results, so mark your main content in your layout:

---
// src/layouts/Base.astro
interface Props {
  title: string;
  tag?: string;
}
const { title, tag } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{title}</title>
  </head>
  <body>
    <header data-pagefind-ignore>
      <a href="/">Home</a> <a href="/search/">Search</a>
    </header>
    <main data-pagefind-body data-pagefind-filter={tag ? `tag:${tag}` : undefined}>
      <slot />
    </main>
    <footer data-pagefind-ignore>Footer text</footer>
  </body>
</html>

The attributes do the following:

  • data-pagefind-body: index only the content inside this element. Once any page on the site uses it, pages without it are left out of the index entirely. That's why the CLI reported 3 pages from 4 files above: the search page itself has no data-pagefind-body, which is exactly what you want.
  • data-pagefind-ignore: skip an element and its children. With the default value, index, filters and metadata inside it are still read; data-pagefind-ignore="all" skips it completely.
  • data-pagefind-filter: attach a filter value to the page, here a tag. The name:value form sets the value directly in the attribute.

Set the lang attribute on <html> as well. Pagefind uses it to pick the right word stemming, and on a multilingual site it builds a separate index per language.

A Mistake to Avoid

My first version put the filter in a hidden element inside the main content: <span data-pagefind-filter="tag" hidden>{tag}</span>. The filter worked, but because the span was inside data-pagefind-body, its text was also indexed, and excerpts started with stray words like "architecture Understanding islands...". Putting the filter in an attribute, as above, keeps it out of the searchable text.

Step 4: Use the JavaScript API for Custom Search

If you want to build your own interface, perhaps with a framework component, Pagefind exposes a JavaScript API from the same bundle:

<script is:inline type="module">
  const pagefind = await import('/pagefind/pagefind.js');

  const search = await pagefind.search('hydrate');
  const results = await Promise.all(search.results.map((r) => r.data()));

  for (const result of results) {
    console.log(result.url, result.meta.title, result.excerpt);
  }
</script>

search() returns lightweight result references, and calling data() on each one loads its URL, metadata and an excerpt with the matching words wrapped in <mark>. Loading data only for the results you display keeps the page fast. Running this against the test site returned:

{
  "url": "/blog/islands/",
  "title": "Understanding islands",
  "excerpt": "Understanding islands. Islands <mark>hydrate</mark> only the interactive parts of a page."
}

Filters work through the second argument. Passing null as the query with a filter returns every page with that value, and pagefind.filters() lists the available values with counts:

const tagged = await pagefind.search(null, { filters: { tag: 'content' } });
const counts = await pagefind.filters();
// counts: { tag: { architecture: 1, content: 1 } }

For search-as-you-type, use pagefind.debouncedSearch() instead of search(), which waits for a pause in typing before running the query. If you render the results with React or another framework, follow the patterns in using React components in Astro and load the component with a client directive.

Step 5: Run Indexing from an Astro Integration

The build script approach works well, but you can also run Pagefind from Astro itself with its Node API. This keeps indexing inside astro build regardless of which command starts it:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import { fileURLToPath } from 'node:url';
import * as pagefind from 'pagefind';

function pagefindIndex() {
  return {
    name: 'pagefind-index',
    hooks: {
      'astro:build:done': async ({ dir, logger }) => {
        const { index } = await pagefind.createIndex();
        const { page_count, errors } = await index.addDirectory({
          path: fileURLToPath(dir),
        });
        if (errors.length) throw new Error(errors.join('\n'));
        await index.writeFiles({
          outputPath: fileURLToPath(new URL('./pagefind/', dir)),
        });
        await pagefind.close();
        logger.info(`Indexed ${page_count} pages`);
      },
    },
  };
}

export default defineConfig({
  integrations: [pagefindIndex()],
});

The astro:build:done hook gives you dir, the output folder, as a URL. After a build, the log showed the integration running:

15:45:59 [pagefind-index] Indexed 4 pages
15:45:59 [build] 4 page(s) built in 193ms
15:45:59 [build] Complete!

Here page_count counted all four HTML files, but a query for every page still returned only the three with data-pagefind-body, so the indexing rules apply the same way as with the CLI. If you'd rather not write this yourself, community integrations such as astro-pagefind package the same idea; check their documentation for current options. The guide to building a custom Astro integration covers hooks like this one in depth.

Static Search with On-Demand Pages

Pagefind indexes HTML files, so it only sees pages that exist in dist/ after the build. In a hybrid project with an adapter, prerendered pages land in the client output directory and are indexed normally, but pages rendered on demand aren't there to read. If you need on-demand content to be searchable, either prerender those pages, add their content to the index yourself with Pagefind's Node API (it can add custom records as well as directories), or use a hosted search service that you update from your server.

Also remember the --site path changes with an adapter. With @astrojs/node, for example, static files are written to dist/client/, so the CLI command becomes pagefind --site dist/client. Using dir from the integration hook handles this for you.

When to Choose Something Else

Pagefind isn't always the answer:

  • Very small sites: if you have a few dozen pages and want fuzzy matching on titles only, a JSON endpoint built from your content collection plus a small library like Fuse.js may be simpler.
  • Typo tolerance, analytics or merchandising: hosted services such as Algolia offer relevance tuning, synonyms and query analytics that a static index doesn't.
  • Frequently changing data: if content changes every few minutes, rebuilding the index on every change may not be practical.

For blogs, documentation and marketing sites, though, Pagefind gives you good results with almost no maintenance.


FAQ: Static Search in Astro

Not directly, because the index is generated from the built site. Run npm run build and then npm run preview to test search locally. You can keep developing the rest of the site with astro dev as usual.

The Pagefind files only exist after the build, so Astro must not try to resolve or bundle them. A script with is:inline, or with any attribute other than src such as type="module", is output unchanged, and the browser then loads it from /pagefind/ at runtime.

Add data-pagefind-body to the element that wraps your main content, usually in your layout, and data-pagefind-ignore to anything inside it that shouldn't be searchable. Pages without data-pagefind-body are then excluded entirely.

Pagefind 1.5 introduced the Component UI, built from web components such as pagefind-modal and pagefind-searchbox, and it replaces the older Default UI. The old files are still generated, but new projects should use the Component UI.

The index is split into small chunks and the browser downloads only the chunks needed for each query, so even large sites stay fast. The total size on disk grows with your content, but visitors never download all of it.

Yes. Add data-pagefind-filter to your pages, for example data-pagefind-filter="tag:astro", then use the pagefind-filter-dropdown or pagefind-filter-pane components, or pass filters to the JavaScript API.


Conclusion

Adding search to a static Astro site doesn't require a server. Install Pagefind, run it after astro build with --site dist, and drop the Component UI's web components into a page, loading the script with is:inline because it's generated after the build. Mark your main content with data-pagefind-body, keep navigation out with data-pagefind-ignore, and use filter attributes for tags or sections.

When you need more control, the JavaScript API lets you build your own interface, and the Node API lets an Astro integration handle indexing automatically, including in projects with an adapter. For the vast majority of content sites, that combination gives fast, private search at no cost and with very little to maintain.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper