Alright, let’s talk about Fuse.js. If Lunr.js is the meticulous librarian who needs everything indexed and catalogued just so, Fuse.js is the brilliant, slightly scatterbrained friend who can find your lost keys by vaguely describing the general area you might have left them in. It’s a fuzzy search library, and “fuzzy” is the operative word here. It doesn’t need a pre-built index; it just takes your content and a search term and, through a kind of textual witchcraft, finds matches even when the words are misspelled, out of order, or just kinda-sorta similar.

This makes it a fantastic, low-configuration choice for smaller Hugo sites. The trade-off? Performance. Since it’s searching the actual text on the fly, it can get sluggish with more than a few hundred kilobytes of data. We’ll get to that.

The Basic Setup: No Index, No Problem

The beauty of Fuse.js is its simplicity. You don’t generate an index at build time. You do need to generate a JSON file that contains all the content you want to search, but that’s a one-liner in Hugo. We use the getJSON function. Here’s the minimal setup.

First, create a JSON index for your site. In your hugo.toml (or config.toml), add an output format for JSON:

[outputs]
home = ["HTML", "RSS", "JSON"] # Add "JSON" to the home output

[outputFormats]
[outputFormats.JSON]
mediaType = "application/json"
baseName = "index"

Then, in layouts/_default/index.json, you can create a template that spits out your site’s content in a format Fuse.js can digest:

{{- $.Scratch.Add "index" slice -}}
{{- range where site.RegularPages "Type" "not in" (slice "search") -}}
  {{- $.Scratch.Add "index" (dict "title" .Title "permalink" .Permalink "content" (.Plain | htmlUnescape)) -}}
{{- end -}}
{{- ($.Scratch.Get "index") | jsonify -}}

Now, yoursite.com/index.json will be a machine-readable list of your pages. Next, we build the search page itself, typically at yoursite.com/search/.

Your Search Page Template

Your search page (e.g., layouts/search/list.html) needs to pull in that JSON file and initialize Fuse. Here’s the core of it:

<!-- The Search Interface -->
<input type="text" id="searchInput" placeholder="Search for anything...">
<div id="searchResults"></div>

<!-- The Logic -->
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
<script>
  // Fetch our Hugo-generated JSON data
  fetch('/index.json')
    .then(response => response.json())
    .then(pages => {
      const fuse = new Fuse(pages, {
        keys: ['title', 'content'], // The object keys to search in
        includeScore: true,        // Useful for displaying match confidence
        ignoreLocation: true,       // Greatly improves fuzzy matching across text
        threshold: 0.4,             // The magic 'fuzziness' knob. 0.0 is exact, 1.0 is nonsense.
        minMatchCharLength: 2       // Avoid matching single characters
      });

      const input = document.getElementById('searchInput');
      const results = document.getElementById('searchResults');

      input.addEventListener('keyup', () => {
        if (input.value.length > 1) { // Don't search on a single character
          const searchResults = fuse.search(input.value);
          results.innerHTML = ''; // Clear previous results
          searchResults.forEach(result => {
            // Build your result links. 'result.item' is your original page object.
            const link = `<article><h3><a href="${result.item.permalink}">${result.item.title}</a></h3><p>...${snippetFromContent(result.item.content, input.value)}...</p></article>`;
            results.innerHTML += link;
          });
        } else {
          results.innerHTML = '';
        }
      });
    });

  // A crude function to get a text snippet around the first match
  function snippetFromContent(content, term) {
    const index = content.toLowerCase().indexOf(term.toLowerCase());
    const start = Math.max(0, index - 50);
    const end = Math.min(content.length, index + term.length + 50);
    return content.substring(start, end);
  }
</script>

Tuning the Fuzziness: Don’t Drive Blindfolded

The default Fuse.js options are, frankly, not great. They’ll make your search feel broken. You must tune these. The most important option is threshold.

  • threshold: 0.0: Requires a perfect match. Defeats the purpose of Fuse.
  • threshold: 0.2: Quite strict. Good for direct, slightly misspelled matches.
  • threshold: 0.4 (shown above): The sweet spot for most sites. It’s forgiving without being ridiculous.
  • threshold: 0.8: “Did you mean… literally anything?” The results will be comically bad.

Always set ignoreLocation: true. This allows matches to be found anywhere in the text, not just near the beginning, which is crucial for a good user experience. minMatchCharLength: 2 is also essential unless you want a search for “I” to return every article containing the word “is”.

The Inevitable Performance Talk

Here’s the brutal truth: Fuse.js is not for big sites. It’s running its fuzzy matching algorithm in the user’s browser, against the entire text content of your site. If your index.json file is 5MB, that’s 5MB of data downloaded and then searched on every keystroke. It will lag. It will make your user’s laptop fan spin up. It’s a terrible experience.

Best Practice: Use Fuse.js for sites with less than 150 pages of primarily text content. If your site is larger, or if you have a lot of code snippets or other non-searchable cruft in your pages, you’ve outgrown Fuse.js. This is the moment you look at Lunr.js (for a client-side index) or Pagefind (for a pre-built, segmented index). Fuse.js is your brilliant friend for small projects, but you wouldn’t ask them to organize a stadium-sized event. Know its limits.