Indexing Hugo Sites with Pagefind
Hugo is the fastest mainstream static site generator and ships almost everything a large site needs — except search. For years the options were a hand-built JSON index rendered by a template and queried with Fuse.js or Lunr, or a hosted service. Pagefind changes that: it indexes the HTML Hugo already wrote, produces a fragmented index that stays small at any size, and needs no Hugo-side code at all.
This guide adds Pagefind to a 12,000-page Hugo documentation and blog site, turns Hugo taxonomies into search filters, handles multilingual output and measures the build-time and reader-side cost. The broader choice of search tools is in Search for Static Sites; Hugo's own build performance is covered in Hugo Build Times for Large Repositories.
Prerequisites
- Hugo extended 0.128 or later.
- Node.js available in CI, or the standalone Pagefind binary if you want to avoid Node entirely.
- Templates you can edit:
layouts/_default/baseof.htmlandlayouts/_default/single.htmlat minimum.
Step 1: Replace the JSON Index Template
Many Hugo sites already have search via an index.json output format that dumps every page's content into one file. Measure it before removing it — on this site public/index.json was 14.2 MB uncompressed and 3.1 MB gzipped, downloaded in full before the first Fuse.js query. Remove the JSON output format from [outputs] home in hugo.toml and delete the template; Pagefind replaces both.
# hugo.toml — before: outputs.home = ["HTML", "RSS", "JSON"]
[outputs]
home = ["HTML", "RSS"]
Step 2: Mark Content and Filters in Templates
Wrap the article body so Pagefind ignores the header, sidebar and footer, weight the title, and emit taxonomy terms as filters:
{{/* layouts/_default/single.html */}}
{{ define "main" }}
<article data-pagefind-body
data-pagefind-meta="section:{{ .Section | humanize }}"
{{ with .Params.weight }}data-pagefind-sort="weight:{{ . }}"{{ end }}>
<h1 data-pagefind-weight="7">{{ .Title }}</h1>
{{ range .GetTerms "tags" }}
<span hidden data-pagefind-filter="tag">{{ .LinkTitle }}</span>
{{ end }}
<span hidden data-pagefind-filter="type">{{ .Type | humanize }}</span>
{{ .Content }}
</article>
{{ end }}
List pages, tag pages and the homepage use other templates without data-pagefind-body, so they fall out of search automatically — which is what readers want, since a tag archive is never the answer to a query.
Step 3: Add the Search UI as a Partial
Put the trigger and lazy loader in a partial so every layout gets the same behaviour:
{{/* layouts/partials/search.html */}}
<button type="button" id="search-open" aria-haspopup="dialog">{{ i18n "search" | default "Search" }}</button>
<dialog id="search-dialog" aria-label="{{ i18n "search" | default "Search" }}"><div id="search"></div></dialog>
<script type="module">
const dlg = document.getElementById('search-dialog');
let ready = false;
document.getElementById('search-open').addEventListener('click', async () => {
if (!ready) {
document.head.insertAdjacentHTML('beforeend',
'<link rel="stylesheet" href="{{ "pagefind/pagefind-ui.css" | relURL }}">');
const { PagefindUI } = await import('{{ "pagefind/pagefind-ui.js" | relURL }}');
new PagefindUI({ element: '#search', showSubResults: true, showImages: false });
ready = true;
}
dlg.showModal();
});
</script>
Using relURL keeps the paths correct when the site is served under a baseURL subpath, such as a GitHub Pages project site — see Deploying to GitHub Pages with Actions.
Step 4: Multilingual Output
Hugo's multilingual mode writes each language under its own prefix and sets <html lang="{{ site.Language.LanguageCode }}"> in the base template. Pagefind reads that attribute and builds a separate index per language automatically. Two Hugo-specific details matter: set languageCode explicitly per language in hugo.toml (it defaults to empty on some configurations, which produces pages with lang="" that go into an unnamed index), and use the same code form everywhere (de, not a mix of de and de-de). The full reasoning is in Multilingual Search on Static Sites.
Step 5: Run It in CI
Add Pagefind directly after Hugo in the build job. With the workflow from How to Set Up GitHub Actions for Hugo Deployments:
- name: Build
run: hugo --gc --minify
- name: Index for search
run: npx -y pagefind@1.3.0 --site public --output-subdir pagefind
- name: Fail if index is empty
run: test "$(ls public/pagefind/fragment | wc -l)" -gt 1000
The final step guards against the most common silent failure: a template change that drops data-pagefind-body, after which Pagefind indexes nothing and the search box returns no results while the build stays green.
Ranking a Mixed Docs and Blog Site
This site mixes reference docs with a blog, and the first week of real queries showed blog posts outranking the docs they described — a post announcing a feature naturally repeats its name more often than the reference page does. Three adjustments fixed that without hiding posts. Blog templates got data-pagefind-weight="0.6" on the article body, so a post needs a considerably stronger match to beat a guide. Reference pages gained data-pagefind-meta="section:Reference" so results show where each hit lives. And the UI's type filter defaults to showing both, but the result list groups by type with docs first. After the change, the share of first-result clicks landing on docs pages rose from 58% to 81% over two weeks, while clicks on posts continued at a steady rate from readers who wanted the announcement rather than the reference.
Measured Impact
The 12,000-page site (9,840 single pages, 2,160 list and taxonomy pages) moved from a Hugo JSON index queried with Fuse.js to Pagefind. Build times are from GitHub Actions; reader numbers are from Chrome DevTools on a Moto G Power profile over fast 4G.
| Measure | Hugo JSON + Fuse.js | Pagefind |
|---|---|---|
| Hugo build | 11.4 s (incl. JSON output) | 9.1 s |
| Indexing step | — | 26 s |
| Search download before first result (gzip) | 3.1 MB | 146 KB |
| Time to first result | 6.8 s | 0.6 s |
| Main-thread time for first query | 1,900 ms (JSON parse + Fuse) | 90 ms |
| Right page first, 40 top queries | 24 | 34 |
Relevance improved mostly from the content boundary. The Fuse.js index had included each page's full rendered text including shortcode output such as repeated admonition labels, and fuzzy matching across 3 MB of text returned many near-misses.
Keeping the Indexing Step Fast
Pagefind's 26 seconds is now the slowest part of the build, so it is worth knowing where it goes. Most of it is HTML parsing across 12,000 files; pages excluded by the missing data-pagefind-body still have to be read. Three measures kept it in check. Running on a 4-vCPU runner rather than 2 cut it to 17 seconds, because Pagefind parallelises parsing. Excluding large generated directories with --exclude-selectors or the glob option (for example, a /releases/ archive with 3,000 pages nobody searches) removed another 5 seconds. And for preview deploys, where search is rarely what reviewers are testing, a PAGEFIND_SKIP=1 environment variable can skip indexing entirely — keep it on for production and for any pull request that touches templates.
Pitfalls & Rollback
- A template refactor removes
data-pagefind-body. Search goes empty while the build passes. Keep the fragment-count check in CI. - Hidden filters in the wrong place. Filter spans must be inside the
data-pagefind-bodyelement, or they are ignored. - Shortcode noise. Repeated shortcode labels such as "Note" or "Warning" in every admonition end up indexed on thousands of pages; wrap the label in
data-pagefind-ignore. - Absolute paths under a subpath
baseURL. Hard-coded/pagefind/breaks on project sites; always userelURL. - Long-cached entry file. Cache
pagefind.jsandpagefind-entry.jsonbriefly; the rest ofpagefind/is fingerprinted and can be immutable. - Rollback: restore the JSON output format and the old search partial in one commit. The Pagefind step can stay in CI harmlessly until you remove it.
Conclusion
Pagefind fills Hugo's one conspicuous gap with no Hugo code: a body attribute and a few hidden filter spans in single.html, a lazy-loading partial and one CI step. On a 12,000-page site that replaced a 3.1 MB JSON index with a 146 KB first-query download, cut time to first result from 6.8 seconds to 0.6, and answered ten more of the top forty queries correctly — at the price of about 26 seconds per production build.
FAQ
Does Pagefind need a Hugo module or plugin?
No. Pagefind reads the HTML Hugo writes to the public folder, so the integration is a command after hugo plus a few attributes in your templates. No Go code or Hugo module is involved.
How do Hugo taxonomies become search filters?
Emit a data-pagefind-filter attribute for each term in your single-page template, for example one per tag. Pagefind indexes them as filter values that the search UI can show as facets.
How long does Pagefind take on a large Hugo site?
On a 12,000-page Hugo site, Hugo built in 9 seconds and Pagefind indexed the output in 26 seconds on a GitHub Actions runner. Indexing is usually slower than Hugo itself, but still well under a minute.
Can I run Pagefind with hugo server?
Pagefind needs built files, so run hugo and pagefind once, then serve with pagefind --serve or copy the pagefind folder into static for development. Most teams test search against the built output rather than the live-reload server.
Related
- Parent: Search for Static Sites — comparing static search tools.
- Hugo Build Times for Large Repositories — keeping the Hugo half of the build fast.
- Adding Pagefind to an Astro Site — the same tool with a relevance check in CI.
- Multilingual Search on Static Sites — per-language indexes in depth.
- Hugo partialCached for Faster Builds — trimming template time on the same site.