Build Custom Hugo Search Without Algolia (Fuse.js + JSON Index)
Ship client-side search on Cloudflare Pages free tier. 800 posts, sub-50ms queries, no SaaS bill.
Tested on: Hugo 0.139.4, Fuse.js 7.0.0, 812 posts, Cloudflare Pages Rejected: Algolia ($35/mo), Pagefind (good but wanted JSON control)
Goal
Site search that works offline after first load, indexes tags + descriptions, weighs title higher than body. Budget: $0.
Step 1: JSON output format
hugo.toml:
[outputs]
home = ["HTML", "RSS", "JSON"]
Create layouts/_default/list.json (for home) or layouts/index.json:
{{- $pages := where site.RegularPages "Params.searchable" "!=" false -}}
[
{{- range $i, $p := $pages -}}
{{- if $i }},{{ end }}
{
"title": {{ $p.Title | jsonify }},
"permalink": {{ $p.Permalink | jsonify }},
"summary": {{ ($p.Summary | plainify | truncate 180) | jsonify }},
"tags": {{ $p.Params.tags | default (slice) | jsonify }},
"date": {{ $p.Date.Format "2006-01-02" | jsonify }}
}
{{- end -}}
]
Build → public/index.json (~420 KB for 812 posts).
Step 2: search UI partial
layouts/partials/search.html:
<div id="site-search" class="site-search">
<input id="search-input" type="search" placeholder="Search…" autocomplete="off" aria-label="Search">
<ul id="search-results" class="search-results" hidden></ul>
</div>
<script type="module">
import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.mjs';
const input = document.getElementById('search-input');
const list = document.getElementById('search-results');
let fuse;
fetch('{{ "index.json" | relURL }}')
.then(r => r.json())
.then(docs => {
fuse = new Fuse(docs, {
keys: [
{ name: 'title', weight: 0.6 },
{ name: 'tags', weight: 0.25 },
{ name: 'summary', weight: 0.15 }
],
threshold: 0.35,
ignoreLocation: true
});
});
input.addEventListener('input', () => {
const q = input.value.trim();
if (!q || !fuse) { list.hidden = true; return; }
const hits = fuse.search(q, { limit: 8 });
list.innerHTML = hits.map(h => `
<li><a href="${h.item.permalink}">${h.item.title}</a>
<small>${h.item.summary}</small></li>`).join('');
list.hidden = hits.length === 0;
});
</script>
Include in header: {{ partial "search.html" . }}
Step 3: exclude pages
Front matter:
searchable: false # legal pages, thank-you pages
Errors encountered
404 on /index.json in production
BaseURL mismatch. Set:
baseURL = "https://myblog.com/"
Cloudflare build must use same value in env HUGO_BASEURL.
JSON parse error: invalid character '<'
index.json returned HTML 404 page. [outputs] was under wrong table — belongs at root, not [params].
CORS blocked fetch index.json
Only happens on file:// local testing. Use hugo server.
Performance
Gzip index.json on CDN — 420 KB → 89 KB on Cloudflare.
Verify
hugo --minify
curl -s http://localhost:1313/index.json | jq 'length'
# 812
curl -s http://localhost:1313/index.json | jq '.[0]'
Manual: search typo-tolerant query hug shortcod → finds "Hugo Shortcodes" post.
Optional: debounce
let t;
input.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(runSearch, 120);
});
Shipped in production 2026-05-10. Zero Algolia invoice.