Hugo Search with Fuse.js
Why Search Matters for a Static Blog#
Hugo generates static HTML — there’s no backend to query. Adding search requires either an external service or a client-side solution.
Search Solutions Comparison#
| Solution | Type | Index Size | Setup | Best For |
|---|---|---|---|---|
| Fuse.js | Client-side fuzzy | Full JSON in memory | Low | Small-medium blogs |
| Pagefind | Client-side chunked | Small binary chunks | Very low (post-build CLI) | Any size |
| Lunr.js | Client-side full-text | Medium-large | Medium | Precise text search |
| Algolia | Hosted service | Server-side | High (API keys, sync) | Commercial/docs sites |
Trade-offs of Fuse.js:
- Pro: no backend, no API keys, no build step, privacy-friendly, fuzzy matching
- Pro: most popular choice in Hugo ecosystem (used by PaperMod, Stack, Blowfish, Congo)
- Con: loads entire index into browser memory — not ideal for 1000+ posts
- Con: fuzzy matching can return less precise results than full-text search
How It Works#
Build Time (Hugo) Runtime (Browser)
┌──────────────┐ ┌──────────────────────┐
│ hugo build │ │ User opens search │
│ │ │ │ │
│ index.json ──┼── static file ───>│ fetch("/index.json") │
│ (all posts) │ on server │ │ │
└──────────────┘ │ Fuse.js builds │
│ in-memory index │
│ │ │
│ User types query │
│ │ │
│ fuse.search("tcp") │
│ │ │
│ Ranked results │
│ rendered as HTML │
└──────────────────────┘
Hugo and Fuse.js are completely independent. Hugo generates a JSON file at build time; Fuse.js fetches and searches it at runtime in the browser.
Implementation#
1. Generate JSON Index#
Add JSON to Hugo’s home output formats in config.toml:
[outputs]
home = ["HTML", "RSS", "JSON"]
Create layouts/_default/index.json to define the index schema:
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "permalink" .Permalink "content" .Plain "date" (.Date.Format "2006-01-02")) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}
Hugo automatically generates public/index.json on every build — no extra step needed.
2. Inline Search Bar in Header#
Override layouts/partials/menu.html to add a search bar right-aligned in the navigation:
<div id="search-wrapper">
<div id="search-bar">
<svg>...</svg>
<input type="text" id="search-input" placeholder="Search posts..." autocomplete="off" />
<kbd id="search-kbd">/</kbd>
</div>
</div>
The search bar includes:
- A magnifying glass icon on the left
- An input field with placeholder
- A
/keyboard hint on the right (hidden when typing)
Results appear in a dropdown panel anchored below the search bar.
3. Fuse.js Configuration#
fuse = new Fuse(data, {
keys: [
{ name: "title", weight: 0.5 }, // title matches ranked highest
{ name: "tags", weight: 0.3 }, // then tags
{ name: "content", weight: 0.2 }, // content matches
],
includeMatches: true,
minMatchCharLength: 2,
threshold: 0.5, // 0 = exact match, 1 = match anything
});
weightcontrols how much each field contributes to the ranking scorethresholdcontrols how fuzzy the matching is — lower means stricter- The index is lazy-loaded on first focus, not on page load
4. Include in Every Page#
Override layouts/_default/baseof.html and add the search partial before </body>:
{{ partial "search.html" . }}
</body>
Important: the partial must be in <body>, not <head> — it contains script and style elements that manipulate DOM.
5. Deduplication#
Fuse.js can return the same post multiple times when it matches on different keys (title, tags, content). Deduplicate by permalink before rendering:
const raw = fuse.search(query);
const seen = new Set();
const results = raw.filter((r) => {
if (seen.has(r.item.permalink)) return false;
seen.add(r.item.permalink);
return true;
});
Trigger Methods#
| Method | How |
|---|---|
| Click | Click the search bar in navigation |
| Keyboard | / or Cmd+K (Mac) / Ctrl+K (Windows) to focus |
| Close | Escape to blur, or click outside |
File Structure#
layouts/
├── _default/
│ ├── baseof.html # overrides theme, includes search partial
│ └── index.json # search index template
└── partials/
├── menu.html # overrides theme, adds inline search bar
└── search.html # search JS + CSS + results dropdown
config.toml # JSON output format