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.html and layouts/_default/single.html at 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.

How Hugo templates feed Pagefind Hugo renders single pages with a data-pagefind-body article, a weighted title, and hidden tag and type filter elements. List and taxonomy pages render without the attribute. Pagefind then indexes only single pages, records tag and type as filters, and the search UI shows them as facets. Templates decide what search sees single.html article data-pagefind-body h1 weight 7 filter: tag, type list.html, terms.html no attribute → not indexed pagefind --site public 9,840 pages indexed index fragments content + weights filter index 412 tags · 3 types 2,160 list and taxonomy pages skipped automatically: they never answer a query
The hidden filter spans carry no visual weight but give readers tag and type facets for free.

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.

Build job with a search guard A four-step CI job: Hugo builds in 9 seconds, Pagefind indexes in 26 seconds, a guard checks that more than 1,000 fragments exist and fails the job if not, and only then does the deploy step run. Index, then prove the index is not empty, then deploy hugo --minify 9 s pagefind 26 s fragments > 1,000? guard, 0.1 s deploy fail the job yes no
A one-line guard turns "search silently returns nothing" into a red build on the pull request that caused it.

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.

MeasureHugo JSON + Fuse.jsPagefind
Hugo build11.4 s (incl. JSON output)9.1 s
Indexing step26 s
Search download before first result (gzip)3.1 MB146 KB
Time to first result6.8 s0.6 s
Main-thread time for first query1,900 ms (JSON parse + Fuse)90 ms
Right page first, 40 top queries2434
Time to first result and build cost, before and after Left: time to first result fell from 6.8 seconds with the Hugo JSON index and Fuse.js to 0.6 seconds with Pagefind. Right: total build time rose from 11.4 seconds to 35.1 seconds, of which 26 seconds is Pagefind indexing. Readers gain six seconds; the build pays twenty-four Time to first result 6.8 s 0.6 s JSON + Fuse Pagefind CI build time 11.4 s 35.1 s before hugo + pagefind Moto G Power profile for readers; GitHub Actions ubuntu-latest for builds
The trade is lopsided in readers' favour: 24 extra seconds once per deploy against six seconds saved on every search session.

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-body element, 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 use relURL.
  • Long-cached entry file. Cache pagefind.js and pagefind-entry.json briefly; the rest of pagefind/ 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.