Adding Search to Hugo with Pagefind

April 4, 2026

#Hugo #Pagefind #Search #Guide


Screenshot of the search page on this blog, showing a search bar and results for "Docker".

I Have Four Posts

Four. That’s the entire content library of this blog. And I just spent an evening adding search to my Hugo site with Pagefind.

You could literally scroll for three seconds and find every post I’ve ever written. But no, I needed a search bar. Because what if someone lands on my site and desperately needs to find that one article about wallpapers but can’t be bothered to move their eyes slightly downward? What then? Chaos. Anarchy. An unacceptable user experience.

The real reason is that I’m going to keep writing stuff, and adding search to a static site with 200 posts sounds way more annoying than adding it to one with four. So I’m doing it now. Future me can thank current me later. Or not. He’s probably still procrastinating on something else.

Hugo generates static HTML files. No server. No database. No nothing. Your entire website is a pile of .html files sitting in a folder. Which is why I love it. Also why adding search is a bit of a “go screw yourself” situation. Any search has to happen client-side, in the browser, with no backend to lean on.

The traditional approach is painful. You create a Hugo template that vomits all your content into a JSON file. Then you load a JavaScript library like Fuse.js or Lunr.js that downloads that entire JSON blob into the visitor’s browser and does fuzzy string matching on it. Every visitor. Every page load. The whole damn index.

For four posts? Sure, who cares. For 500 posts? You’re shipping a 5MB JSON file to people who just wanted to search for “Docker.” And Fuse.js has this fun habit where you search for “Traefik” and it goes “did you mean: traffic, tragic, trick?” No I did not. I meant Traefik. The thing I typed. Thanks for nothing.

Lunr.js is… I mean it’s not even maintained anymore. Last release was like 2020. The GitHub issues are piling up. It’s that project on life support where nobody wants to be the one to pull the plug.

Pagefind Is Stupidly Simple

Pagefind does something smart: it ignores Hugo completely.

Instead of making you write JSON templates and wire up search logic and deal with Hugo’s output format configuration, Pagefind just… looks at your HTML. You build your site with Hugo like normal. Then you point Pagefind at the output. It crawls public/, reads the rendered HTML, builds its own index.

But here’s the actually clever part. It doesn’t create one fat index file. It splits everything into tiny chunks and only loads the ones relevant to what you’re searching for. The whole thing, JavaScript plus WASM runtime plus whatever index fragments your query needs, comes in under 100KB per search. Compared to “download the entire blog as JSON and pray,” that’s not bad.

And it ships with a UI. You don’t write any search frontend code. None. You add a <div>, include two files, write four lines of JavaScript. I kept waiting for the catch.

There isn’t one.

Setting Up Pagefind in Hugo

Search Page

Make content/search.md:

+++
title = 'Search'
url = '/search/'
layout = 'search'
+++

Thrilling content right there.

Layout

Create layouts/_default/search.html:

{{ define "main" }}
<div class="search-container">
  <link href="/pagefind/pagefind-ui.css" rel="stylesheet">
  <script src="/pagefind/pagefind-ui.js"></script>
  <div id="search"></div>
  <script>
    window.addEventListener('DOMContentLoaded', () => {
      new PagefindUI({
        element: "#search",
        showSubResults: true
      });
    });
  </script>
</div>
{{ end }}

Wrap it in whatever your theme’s base template expects. The Pagefind part is the stuff between the <div> tags. That’s all the frontend you’ll ever write for this. I know. I was suspicious too.

Build Command

Before:

hugo --minify

After:

hugo --minify && pagefind --site public

One extra command appended with &&. I kept staring at the docs thinking I was missing a step. Nope. That’s the whole integration.

You can run it via npx -y pagefind --site public if you’re into Node, or just download the binary:

wget -qO- https://github.com/CloudCannon/pagefind/releases/download/v1.4.0/pagefind-v1.4.0-x86_64-unknown-linux-musl.tar.gz | tar xz

I went with the binary. No npm, no node_modules, no downloading twelve thousand packages so I can index four blog posts. Just a file that does the thing.

Pagefind with Hugo in Docker

I’m running Hugo in Docker because I apparently enjoy making everything slightly harder for myself. So Pagefind needs to be available after Hugo builds.

My approach is dumb and I like it: download the Pagefind binary, stick it in bin/ in my project, commit it to git.

Yeah yeah, binaries in git, I know. It’s 15MB. I’ll survive. It means zero runtime dependencies, no pulling packages during deploy, no “oh the npm registry is down again and now my blog can’t build.” Just a file in a folder. Revolutionary.

The deploy script:

#!/bin/bash

source hugo-container.conf

LIVE_DIR="/var/www/hugo/hmmr"
LOCK_FILE="/tmp/hugo-hmmr-deploy.lock"
PAGEFIND_BIN="./bin/pagefind"

if [ -f "$LOCK_FILE" ]; then
  echo "Deployment already running. Exiting."
  exit 0
fi

touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT

# Hugo builds the site
docker run --rm \
  -v $(pwd):/src \
  -v "$LIVE_DIR":/target \
  -w /src \
  "$HUGO_IMAGE" \
  --destination /target --minify

# Pagefind indexes it
$PAGEFIND_BIN --site "$LIVE_DIR"

Hugo builds. Pagefind indexes. Two commands, same script. That’s basically it.

The hugo serve Problem

hugo serve renders everything in memory. Doesn’t write to public/. So during development, the Pagefind index doesn’t exist and search just… shows nothing. You type a query, hit enter, silence. Great.

The workaround:

hugo --minify && pagefind --site public --output-subdir ../static/pagefind

This dumps the index into static/pagefind/ instead, which hugo serve actually serves. The index won’t live-reload when you edit posts, so you’d have to rebuild manually. But at least search doesn’t just sit there staring at you.

Honestly I almost never bother with this. I test search after deploying. Four posts. I can verify results by remembering what I wrote. That’s the luxury of having no content.

Don’t Index Your Nav

This one bit me.

Pagefind indexes everything by default. Your header. Your footer. Your tag pages. That sidebar you forgot about. So you search for “Docker” and suddenly get eight results because the word appears in your navigation on every page.

Took me a minute to figure out why search results were such garbage. The fix:

<article data-pagefind-body>
  {{ .Content }}
</article>

Now it only touches what’s inside <article>. Navigation, footer, all that crap? Gone. Should’ve done this first. Would’ve saved me from questioning Pagefind’s intelligence.

You can also kill specific sections within the body:

<div data-pagefind-ignore>
  Pagefind pretends this doesn't exist.
</div>

Make It Not Ugly

The default Pagefind CSS looks like a developer designed it. Because a developer designed it.

CSS custom properties fix most of it:

:root {
  --pagefind-ui-primary: #your-accent-color;
  --pagefind-ui-text: #your-text-color;
  --pagefind-ui-background: #your-bg-color;
  --pagefind-ui-border: #your-border-color;
  --pagefind-ui-border-width: 1px;
  --pagefind-ui-border-radius: 8px;
  --pagefind-ui-font: inherit;
}

Set inherit on the font. Match your colors. Done. I spent longer picking the accent color than setting up the entire search.

Was This Worth It?

For a blog with four posts? No. Obviously not.

But it took maybe fifteen minutes. Twenty lines of code across two files. One extra command in my deploy script. Every new post gets indexed automatically. I never have to touch it again.

And now I have a search bar on a blog that you can read entirely during a bathroom break. Big day.

TL;DR

Pagefind runs after Hugo builds your site. It crawls the HTML, creates a chunked index, gives you a drop-in search UI. No JSON templates, no JavaScript search libraries, no backend. Stick data-pagefind-body on your article wrapper so it doesn’t index your nav and footer. If you’re running Hugo in Docker, keep the binary in your repo and call it after Hugo. Twenty lines of code and one build command. Even for four posts.

Share this post