How to add privacy-friendly full-text search to a static Astro site with Pagefind
Static sites are fast and cheap to host, but they have one awkward gap: search. There’s no backend to run a query against, and bolting on a hosted search service means shipping every visitor’s keystrokes to someone else’s server. I wanted search on this blog without any of that. The answer was Pagefind, and this post walks through exactly how it’s wired up here.
What Pagefind actually is
Pagefind is a search tool built for static sites. Instead of indexing your source files or a database, it indexes the built HTML — the same pages your visitors see. After your site builds, Pagefind crawls the output, chops the index into small chunks, and drops them next to your pages as static assets.
The important part: search runs entirely in the browser. When someone types a query, the browser downloads only the index chunks it needs and does the matching locally. No query ever leaves the device, there’s no server to run, and it stays fast even on a big site because the index is lazy-loaded in fragments.
The build step
Pagefind needs finished HTML to index, so it runs after the Astro build. That’s the whole trick, and it lives in one line of package.json:
// blog/package.json
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}astro build prerenders every page into dist/. Then pagefind --site dist crawls that folder and writes the search index into dist/pagefind/. Because it points at the built output, the index always matches what’s actually published.
Wiring up the integration
To get a ready-made search UI (and to make the pagefind binary available), I use the astro-pagefind integration. It’s registered like any other Astro integration:
// blog/astro.config.mjs
import { defineConfig } from "astro/config"
import pagefind from "astro-pagefind"
export default defineConfig({
integrations: [
// ...other integrations
pagefind(),
],
})That gives me a drop-in <Search /> component I can place on a page.
The search page
The search page mounts Pagefind’s own UI component. Here’s the core of it:
---
// blog/src/pages/search.astro
import Search from "astro-pagefind/components/Search.astro"
---
<div class="pagefind-shell">
<Search
className="pagefind-ui w-full"
configOptions={{ showImages: false, excerptLength: 40 }}
/>
</div>
<p>Search runs in your browser — no query leaves the page.</p>The <Search /> component renders the input box and results list, and hydrates on the client. configOptions are passed straight through to Pagefind’s UI: showImages: false keeps results text-only, and excerptLength: 40 controls how many words of context show around a match.
The ”/” shortcut and shareable ?q= links
Two touches make the search feel less like a static widget and more like a real app. Both live in a small <script> on the search page (Astro ships it to the client). The Pagefind box is a custom element that upgrades asynchronously, so the script polls briefly for its input before enhancing it.
First, deep-linking. If someone lands on the page with a ?q= query string, the script reads it and pushes the value into the search box, so a shared link runs the search automatically:
// blog/src/pages/search.astro (client script, trimmed)
const params = new URLSearchParams(window.location.search)
const q = params.get("q")
if (q) setQuery(input, q)
input.focus({ preventScroll: true })Filling a framework-controlled input is fiddly — you can’t just set input.value, because the widget won’t notice. The setQuery helper uses the native value setter and then dispatches an input event so Pagefind reacts:
function setQuery(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set
setter?.call(input, value)
input.dispatchEvent(new Event("input", { bubbles: true }))
}Going the other way, as the visitor types, the current query is written back into the URL (debounced by 400ms) so the link is always shareable:
input.addEventListener("input", () => {
clearTimeout(debounce)
debounce = setTimeout(() => {
const url = new URL(window.location.href)
if (input.value) url.searchParams.set("q", input.value)
else url.searchParams.delete("q")
window.history.replaceState({}, "", url)
}, 400)
})The / keyboard shortcut — press / anywhere to jump to search — is surfaced right in the UI with a little <kbd>/</kbd> hint, matching a convention people know from GitHub and docs sites.
The no-JS, crawlable fallback
Here’s a subtlety that’s easy to miss: because the Pagefind widget is client-only, search engines never see it. A crawler with JavaScript disabled would find an empty box. So the search page also renders a plain, static archive of every post, grouped by year, built at prerender time from the same typed content collection helpers (getPublishedPosts and the getAllTags from the tag system):
---
// blog/src/pages/search.astro
import { getPublishedPosts, getAllTags } from "@/utils/posts"
const posts = await getPublishedPosts()
const tags = await getAllTags()
const byYear = new Map<number, typeof posts>()
for (const post of posts) {
const year = post.data.date.getFullYear()
const list = byYear.get(year) ?? []
list.push(post)
byYear.set(year, list)
}
const years = [...byYear.keys()].sort((a, b) => b - a)
---That archive is what Google actually indexes, it works with JavaScript off, and it doubles as a “browse everything” view for people who’d rather not type. The interactive Pagefind box is progressive enhancement layered on top.
flowchart LR A[astro build] --> B[dist/ HTML] B --> C[pagefind --site dist] C --> D[dist/pagefind/ index] D --> E[browser downloads chunks on demand] E --> F[search runs locally]
What to remember
- Pagefind indexes your built HTML after the build —
astro build && pagefind --site dist. - Search runs fully in the browser; no server and no query ever leaves the visitor’s device.
astro-pagefindgives you a drop-in<Search />component plus the CLI binary.- A little client script adds a
/shortcut and shareable?q=deep links — set the input via its native setter so the widget notices. - Ship a static archive as a crawlable, no-JS fallback, since the client-only widget is invisible to search engines.
Related reading: this search page is exactly where the custom 404 page sends lost visitors.