NNicheStackTutorials
← All tutorials
Hugo

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.

·13 min read·Hugo
Build Custom Hugo Search Without Algolia (Fuse.js + JSON Index) cover

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.

Tags: HugoSearchFuse.jsStatic Site