DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on Originally published at sourcier.uk

Adding search to a static Astro blog

Original post: Adding search to a static Astro blog

Series: Part of How this blog was built: documenting every decision that shaped this site.

Static sites have an obvious gap: there is no server to run a query against. Everything lives in pre-built HTML files on a CDN. The conventional workarounds are to ship users off to Google with a site: filter, drop in Algolia, or accept that search just isn't a feature.

None of those felt right. Sending users to Google is a dead end. Algolia has a free tier, but it's an external dependency that needs a sync pipeline and will eventually bill you. And a developer blog without search is awkward the moment you have more than a handful of posts.

Pagefind is the answer for static sites. It crawls the built HTML, generates a WASM-based index at build time, and serves everything as static files alongside your site. No server, no external service, no runtime dependency. The index ships with the site.

Architecture overview

The full setup spans build time, the content repo, and the browser. Here's where the pieces connect:

Mermaid diagram

Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/search-pagefind-astro

The modal UI

The search trigger is a single icon button in the navbar. Clicking it opens a full-viewport overlay with an input and a scrollable results list:

Search modal UI wireframe showing the navbar search icon, backdrop overlay, input strip, and three result rows with thumbnails

Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/search-pagefind-astro

The highlighted row shows keyboard focus. Escape or clicking the backdrop closes it. / move focus through results. Enter navigates. Click the expand icon on the wireframe to view it fullscreen.

How Pagefind works

After astro build produces the dist/ directory, you run Pagefind against it:

npx pagefind --site dist
Enter fullscreen mode Exit fullscreen mode

Pagefind walks the HTML, extracts text from elements marked with data-pagefind-body, and writes a set of compressed index files into dist/pagefind/. Those files are served as static assets. The search itself runs entirely in the browser via a small WASM module that Pagefind provides.

Install it as a dev dependency:

pnpm add -D pagefind
Enter fullscreen mode Exit fullscreen mode

Then append the Pagefind step to the Netlify build command in netlify.toml:

[build]
  command = "... && astro build && npx pagefind --site dist"
Enter fullscreen mode Exit fullscreen mode

Indexing locally for dev

Pagefind runs post-build and writes its index to dist/pagefind/. The Astro dev server doesn't serve from dist/, so the index isn't available during pnpm dev. The solution is a local script that builds the site, runs Pagefind, and copies the result to public/pagefind/, which the dev server does serve:

"search:index": "pnpm assets:sync && SHOW_DRAFTS=true astro build && pagefind --site dist && rm -rf public/pagefind && cp -r dist/pagefind public/pagefind"
Enter fullscreen mode Exit fullscreen mode

The rm -rf before the copy is intentional. Without it, running the script twice causes cp -r to nest dist/pagefind inside an existing public/pagefind, leaving stale index fragments alongside fresh ones. The old fragments don't get replaced: they persist and corrupt results.

Both public/pagefind/ and public/search-thumbnails/ (more on those later) are gitignored, they're generated artifacts, not source files.

Marking content for indexing

By default, Pagefind indexes everything in the <body>. You can tell it to be more selective with data-pagefind-body:

<article class="post" data-pagefind-body>
  <!-- Only this element and its descendants are indexed -->
</article>
Enter fullscreen mode Exit fullscreen mode

Once any element on the site has data-pagefind-body, Pagefind ignores every page that doesn't have it. This is the right default for a blog: you only want post content indexed, not the navigation, footer, and sidebar that appear on every page.

This goes on MarkdownPostLayout.astro, which wraps every post.

A modal, not a search page

The obvious implementation is a /search page with a text input that queries Pagefind. It works, but the UX feels dated: you leave your current context, navigate to a new page, and wait for a result.

The better pattern is a command-palette style modal that opens inline wherever you are. Type a query, see results immediately, navigate with arrow keys, press Enter to go. It feels modern and fast, and it doesn't interrupt the reading flow.

The header gets a magnifying glass button:

<button class="social-icon" aria-label="Open search" data-search-open>
  <!-- Font Awesome icon -->
</button>
Enter fullscreen mode Exit fullscreen mode

Clicking it opens a modal overlay with an input and a results list. Keyboard handling covers the full expected surface: Escape closes, ArrowUp/ArrowDown move through results, Enter follows the active result.

Pagefind's JS API

Pagefind ships a default UI component, but it has strong opinions about styling and it generates its own DOM. Using the JS API directly gives full control:

const pagefind = await import('/pagefind/pagefind.js');
await pagefind.init();

const results = await pagefind.search(query);
const data = await Promise.all(results.results.slice(0, 6).map(r => r.data()));
Enter fullscreen mode Exit fullscreen mode

Each result's data() call returns the page's URL, an excerpt with matched terms highlighted, and whatever was stored in data-pagefind-meta attributes.

The import('/pagefind/pagefind.js') must be a runtime dynamic import. Vite processes static imports at build time, and pagefind.js doesn't exist until after the build runs. The script in Header.astro uses is:inline so Astro doesn't process it through Vite:

<script is:inline>
  // Dynamic import runs in the browser at runtime, not at build time
  const pagefind = await import('/pagefind/pagefind.js');
</script>
Enter fullscreen mode Exit fullscreen mode

Pagefind is only initialised once. The first search triggers the init, and subsequent queries reuse the already-loaded instance.

Metadata: title and cover image

Pagefind reads data-pagefind-meta attributes to store custom fields alongside each result. The initial attempt used a single attribute on the <article> element:

<article data-pagefind-body data-pagefind-meta="title:Choosing the tech stack,image:/_astro/cover.webp">
Enter fullscreen mode Exit fullscreen mode

This produces results where the title field contains "Choosing the tech stack,image:/_astro/cover.webp". The entire string after title: is treated as the title value, including the comma and everything after it.

The correct approach is a separate element per field:

<span data-pagefind-meta="title" class="visually-hidden">Choosing the tech stack</span>
<img data-pagefind-meta="image[src]" src="/search-thumbnails/choosing-the-tech-stack/choosing-the-tech-stack-thumbnail.webp"
     class="visually-hidden" alt="" aria-hidden="true" />
Enter fullscreen mode Exit fullscreen mode

The image[src] syntax tells Pagefind to read the value from the src attribute rather than the text content of the element.

Why visually-hidden and not display:none

The first instinct is to hide these elements with display: none. It seems clean: they're purely for Pagefind's benefit, not for the user.

Pagefind's crawler skips elements with display: none. The meta is never read, and the fields come back undefined in results.

The .visually-hidden pattern keeps the element in the layout engine: it's positioned, sized, and rendered, but visually invisible and excluded from the accessibility tree:

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
Enter fullscreen mode Exit fullscreen mode

Pagefind's crawler reads it. Screen readers ignore it via aria-hidden="true". Users don't see it.

The thumbnail problem

Cover images on this blog go through Astro's image optimisation pipeline, which hashes filenames at build time: cover.webp becomes cover.D7kJPmN_Z3QwX.webp. That hashed path is what Pagefind indexes.

In development, the Astro dev server generates images on demand and serves them at the original relative path, so the hashed path from the build doesn't exist. Pagefind reads the right path from the built HTML, stores it in the index, and it works in production, but the images 404 in dev.

The fix: pre-generate thumbnails as stable, predictably-named files that are served from the same path in both dev and production.

Generating thumbnails with ImageMagick

Each post directory in the content repo gets a <slug>-thumbnail.webp, a 96×96 center-cropped version of the cover image, generated once using ImageMagick:

magick cover.jpg -resize 96x96^ -gravity Center -extent 96x96 choosing-the-tech-stack-thumbnail.webp
Enter fullscreen mode Exit fullscreen mode

The 96x96^ flag resizes to fill the target dimensions (scaling up the smaller dimension), and -gravity Center -extent 96x96 crops to the exact size, keeping the centre of the image.

These thumbnails live alongside the article: colocated in the content repo, committed once, never regenerated unless the cover changes.

Copying thumbnails before build and dev

The thumbnails live in collections/posts/<slug>/<slug>-thumbnail.webp. Astro serves from public/ (in dev) and dist/ (after build), not from collections/. The thumbnails:copy package command mirrors them to public/search-thumbnails/<slug>/<slug>-thumbnail.webp:

"thumbnails:copy": "node scripts/sync-public-assets.mjs thumbnails"
Enter fullscreen mode Exit fullscreen mode

Both pnpm dev and pnpm search:index run pnpm thumbnails:copy first, and pnpm search:index also runs pnpm post-images:copy, so generated assets are always in place before the server starts or the search index is built. The Netlify build command also includes the asset sync step.

The layout then constructs the stable URL directly, no Astro getImage() involved:

const thumbSrc = frontmatter.cover?.thumbnail
  ? `/search-thumbnails/${postId}/${postId}-thumbnail.webp`
  : null;
Enter fullscreen mode Exit fullscreen mode

/search-thumbnails/choosing-the-tech-stack/choosing-the-tech-stack-thumbnail.webp is the same URL in dev and production. The image is always there. No hashing, no broken thumbnails.

Styles must go in global.scss

Astro's scoped <style> blocks add a hash to every class name at build time, something like .search-panel[data-astro-cid-abc123]. Elements injected into the DOM at runtime via innerHTML don't have that hash attribute, so the scoped styles don't apply to them.

Everything that styles the search modal and results lives in global.scss, not in a scoped <style> block in Header.astro.

Design decisions

No search page

A dedicated /search route is the obvious implementation, but it has a friction cost: you leave your current context, wait for a page load, and then navigate back. The modal pattern keeps you where you are. Open, type, go: three steps, no navigation until you've found what you want.

The command-palette pattern is familiar from editors like VS Code's ⌘K and Spotlight. Users already know how it works.

The JS API over the default UI

Pagefind ships a ready-made UI component. It renders its own DOM, injects its own styles, and supports themes. For a blog that already has a design system and a specific interaction pattern in mind, the default UI introduces more constraints than it removes.

The JS API takes three lines to get search results and returns plain data: URL, excerpt, custom meta. The result DOM is written by the same code that writes the rest of the site. No style conflicts, no theme mismatch, no overriding someone else's HTML structure.

Dynamic import, not a static import

You can't statically import pagefind.js because the file doesn't exist at build time. Pagefind generates it after astro build runs. A static import would fail at the Vite bundling step. The runtime dynamic import await import('/pagefind/pagefind.js') sidesteps Vite entirely and loads only when the user first opens the search modal.

The is:inline attribute on the script tag is what keeps Astro from handing it to Vite.

Loading skeleton before results arrive

Pagefind's WASM module takes a moment to load on first use. A blank results area during that gap looks broken. Three skeleton rows, placeholder blocks with a shimmer animation, fill the space immediately, giving the user feedback that something is happening. The skeletons are replaced by real results as soon as the search resolves.

Predictable thumbnail paths

Astro's image optimisation pipeline hashes filenames: cover.webp becomes cover.D7kJPmN.webp. That hash changes every time the image is reprocessed. Pagefind stores whatever URL it finds at index time, which means indexed paths would become stale whenever the build produces a new hash.

Pre-generating thumbnails with a stable, slug-based filename (choosing-the-tech-stack-thumbnail.webp) breaks that dependency. The URL Pagefind indexes is the same URL that will exist in the next build and every build after that.

visually-hidden, not display: none

This is documented in detail in the metadata section above, but the principle is worth making explicit: anything you hide from the DOM with display: none is also hidden from Pagefind's crawler. .visually-hidden is the only pattern that satisfies all three constraints simultaneously: visually absent, accessible-tree absent, and crawler-visible.

Adding a new post

When you write a new post with a cover image, generate the thumbnail once:

magick path/to/cover.jpg -resize 96x96^ -gravity Center -extent 96x96 collections/posts/your-post-slug/your-post-slug-thumbnail.webp
Enter fullscreen mode Exit fullscreen mode

Then add thumbnail: './your-post-slug-thumbnail.webp' as a child of cover: in the post frontmatter. The copy script and index rebuild will pick it up automatically.

Working on something similar?

If you're building a content site or developer blog and would rather not unpick these problems yourself, I'm available for consulting. Get in touch via the contact page.

Top comments (0)