Search is the feature most Gatsby sites skip and then bolt on badly. The usual options were a hosted index (Algolia, Meilisearch) with a build-time sync step and a monthly bill, or a client-side index (FlexSearch, Lunr) shipped as a JSON blob that quietly grows to several megabytes as the site does. Neither fits a static site well: the first adds a service to the deploy that can drift out of sync, the second punishes every visitor with a download of the entire corpus whether they search or not.
Pagefind takes a third approach that suits Gatsby exactly. It runs after the build, over the HTML in public/, and emits a fragmented index that the browser fetches in small pieces only when someone types. No server, no API key, no CMS integration, and the index can never disagree with the site because it was built from the site.
This tutorial wires Pagefind into a Gatsby 5 project end to end: indexing in onPostBuild, marking up what should and should not be indexed, a search UI that does not break SSR, filters from your frontmatter, and the CI and hosting details that trip people up.
Why post-build indexing works well with Gatsby
Gatsby's data layer is not involved at all, which is the point. Whatever produced a page — Markdown, MDX, a headless CMS, a createPages loop over an API — the output is HTML on disk, and that HTML is what a reader actually sees. Indexing it means:
- Content from any source is covered without a per-source integration.
- What is indexed is what is rendered, so a broken template shows up as broken search results instead of silently correct ones.
- The index is a build artifact, deployed atomically with the site. There is no window where the index describes yesterday's content.
The cost is that indexing happens at the end of the build and adds time to it — on the order of a few seconds per thousand pages, which is negligible next to Gatsby's own build.
Step 1: install and index in onPostBuild
npm install --save-dev pagefind
Pagefind ships a Node API, so you do not have to shell out. Add an onPostBuild hook — it runs after Gatsby has written the static HTML, which is exactly what we need to index:
// gatsby-node.js
const path = require('node:path');
exports.onPostBuild = async ({ reporter }) => {
const activity = reporter.activityTimer('Building Pagefind index');
activity.start();
const { createIndex } = await import('pagefind');
const { index, errors } = await createIndex({
// Treat these as separate "roots" if you run a multi-language site
forceLanguage: 'en',
});
if (errors?.length) {
activity.end();
reporter.panicOnBuild('Pagefind failed to create an index', errors);
return;
}
const { page_count } = await index.addDirectory({
path: path.join(process.cwd(), 'public'),
// Do not index Gatsby's internal output or partials
glob: '**/*.html',
});
await index.writeFiles({
outputPath: path.join(process.cwd(), 'public', 'pagefind'),
});
reporter.info(`Pagefind indexed ${page_count} pages`);
activity.end();
};
Build and check the artifacts:
npx gatsby build
ls -la public/pagefind/ | head
du -sh public/pagefind
You should see pagefind.js, a pagefind-entry.json, and a set of fragment/ and index/ chunks. On a 400-page content site the whole directory is typically 1–3 MB, but a visitor who searches downloads only the few chunks their query touches — usually well under 100 KB.
Step 2: tell Pagefind what is content
By default Pagefind indexes the whole <body>, which means your nav, footer, and cookie banner end up in every result's excerpt. Fix that with data attributes in your layout and templates.
Mark the content region:
// src/templates/post.js
<main data-pagefind-body>
<h1>{post.frontmatter.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</main>
Once any page on the site has data-pagefind-body, Pagefind indexes only pages that have it. That single attribute is also your allow-list: put it on post and page templates, leave it off the 404 page, tag archives, pagination pages, and anything else you do not want surfacing as a result.
Exclude sub-regions that live inside the body:
<aside data-pagefind-ignore="all">
<RelatedPosts posts={related} />
</aside>
<div className="callout" data-pagefind-ignore>
Subscribe to the newsletter
</div>
data-pagefind-ignore drops the element from the indexed text; ="all" also drops it from result excerpts and metadata extraction.
Set metadata explicitly rather than letting Pagefind guess:
<main data-pagefind-body>
<h1 data-pagefind-meta="title">{post.frontmatter.title}</h1>
<p data-pagefind-meta="date">{post.frontmatter.date}</p>
<span
data-pagefind-meta={`image:${post.frontmatter.hero.publicURL}`}
hidden
/>
</main>
Anything you attach here comes back on the result object and can be rendered in the results list without a second fetch.
Step 3: filters from frontmatter
Filters are the feature that makes a small site's search feel finished, and they cost one attribute:
<span data-pagefind-filter={`category:${post.frontmatter.category}`} hidden />
{post.frontmatter.tags.map((tag) => (
<span key={tag} data-pagefind-filter={`tag:${tag}`} hidden />
))}
<span data-pagefind-filter={`year:${post.frontmatter.date.slice(0, 4)}`} hidden />
Pagefind builds the facet counts at index time, so the UI can show "Tutorials (24)" without querying anything.
Step 4: a search UI that survives SSR
This is where most Gatsby integrations break. public/pagefind/pagefind.js does not exist during gatsby build — it is written by the hook that runs after the bundle is compiled — so a static import will fail the build. It also must not be resolved by webpack at all. Load it dynamically, at runtime, from the browser only:
// src/components/search.js
import React, { useCallback, useEffect, useRef, useState } from 'react';
export default function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const pagefind = useRef(null);
const load = useCallback(async () => {
if (pagefind.current) return pagefind.current;
// Template literal + webpackIgnore stops webpack resolving this at build time
const mod = await import(/* webpackIgnore: true */ `${__PATH_PREFIX__ || ''}/pagefind/pagefind.js`);
await mod.options({ excerptLength: 25 });
await mod.init();
pagefind.current = mod;
return mod;
}, []);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
setLoading(true);
const pf = await load();
const search = await pf.debouncedSearch(query, {}, 300);
if (search === null || cancelled) return; // superseded by a newer query
const data = await Promise.all(search.results.slice(0, 10).map((r) => r.data()));
if (!cancelled) {
setResults(data);
setLoading(false);
}
}, 100);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [query, load]);
return (
<div className="search">
<label htmlFor="site-search">Search</label>
<input
id="site-search"
type="search"
value={query}
autoComplete="off"
onChange={(e) => setQuery(e.target.value)}
onFocus={load}
placeholder="Search the site"
/>
<div aria-live="polite" aria-atomic="true">
{loading ? 'Searching…' : `${results.length} result${results.length === 1 ? '' : 's'}`}
</div>
<ul>
{results.map((result) => (
<li key={result.url}>
<a href={result.url}>{result.meta?.title || result.url}</a>
<p dangerouslySetInnerHTML={{ __html: result.excerpt }} />
</li>
))}
</ul>
</div>
);
}
Details that matter here:
onFocus={load}warms the index the moment a user touches the box, so the first query feels instant. Nothing is downloaded for visitors who never search.debouncedSearchreturnsnullwhen a newer query has superseded the current one; bailing out onnullprevents flickering results.aria-live="polite"announces the result count to screen readers. A search box that updates a list silently is a WCAG 4.1.3 failure, and it is trivial to avoid.__PATH_PREFIX__keeps the URL correct if the site is deployed under a path prefix.
If you would rather not build a UI at all, Pagefind's bundled pagefind-ui.js gives you a working, styled, keyboard-accessible widget in about ten lines — mount it in a useEffect from the same dynamic-import pattern. It is a perfectly reasonable choice for an internal docs site.
Step 5: make it work in gatsby develop
The hook only runs on gatsby build, so gatsby develop has no index and the search box will throw on load. Two options, in order of preference:
- Build once, serve the index in dev. Run
gatsby buildto producepublic/pagefind, then copy it tostatic/pagefindsogatsby developserves it. Addstatic/pagefindto.gitignore.
npx gatsby build && rm -rf static/pagefind && cp -r public/pagefind static/pagefind
- Degrade gracefully. Wrap the dynamic import in a
try/catchand render "Search is available on the built site" when it fails. Less convenient, but it stops a dev-only 404 from looking like a real bug.
Test the real thing with gatsby serve, not develop — that serves public/ as deployed.
Step 6: CI, caching, and hosting
Three things to check before you call it done.
Do not let a stale cache poison the index. If your CI caches public/ between builds (a common Gatsby build-time trick), deleted pages can survive in public/ and get re-indexed. Either clear public/*.html before the build or accept the cache and add a scheduled full rebuild. This is a real failure mode: search results linking to 404s, with no error anywhere in the log.
Serve the fragments with sane headers. The chunks are content-hashed, so they can be cached hard:
# netlify.toml
[[headers]]
for = "/pagefind/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/pagefind/pagefind-entry.json"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
The entry file is the one that changes every build and must not be cached, or browsers will fetch chunk names that no longer exist.
Assert the index exists. Add a post-build check so a silently skipped hook cannot ship a dead search box:
test -f public/pagefind/pagefind-entry.json || { echo "Pagefind index missing"; exit 1; }
node -e "const e=require('./public/pagefind/pagefind-entry.json');const n=e.languages.en.page_count;if(n<50){console.error('Only '+n+' pages indexed');process.exit(1)}"
A floor on page count catches the case where a template change drops data-pagefind-body and the index quietly shrinks to three pages.
When to use something else
Pagefind is the right default for content sites up to roughly 10,000 pages. Reach for a hosted index when you need:
- Search across content the site does not render — private records, a product catalogue behind auth, anything not in
public/. - Analytics on queries. Pagefind runs entirely in the browser, so nobody sees what people searched for. That is a privacy feature and an intelligence loss; if knowing your top zero-result queries drives your content roadmap, you need a service.
- Typo tolerance and synonyms tuned per query. Pagefind does stemming and partial matching well, but it is not a tunable relevance engine.
- Very large or highly dynamic corpora, where re-indexing on every deploy stops being cheap.
For most Gatsby sites — documentation, marketing, a blog with a few hundred posts — none of those apply, and the honest comparison is a few seconds of build time and zero running cost against a monthly bill and a sync job that can break.
Checklist
-
pagefindindevDependencies, indexing inonPostBuild -
data-pagefind-bodyon content templates only - Nav, footer, related-posts, and CTAs excluded with
data-pagefind-ignore -
titleand any display metadata set withdata-pagefind-meta - Dynamic import with
webpackIgnore, so the build does not try to resolve it -
aria-liveregion and a real<label>on the input - Dev-mode story documented for the next developer
- Cache headers set, entry file uncached
- CI assertion on index presence and page count
Static search should be boring: no keys, no dashboards, no drift. If you would like help adding it to an existing Gatsby project, or a review of a site where search has quietly stopped matching the content, get in touch.