Getting Hugo and Lunr playing nice with each other wasn’t too bad, but I had trouble finding a clear walk-through with current versions of the tools. My starting point was Joseph Earl’s post, Search your Hugo static site using lunr.js. That was the most complete guide I could find and got me … 80% of the way there. I think the hiccups I encountered were due to updates to the Lunr code and some breaking changes that happened since he wrote his post.

The first step of his guide worked well for me, though I made some small edits to suit my setup. In my theme folder I added layouts/search-index/single.html and provided the following contents:

{{- $.Scratch.Add "index" slice -}}
{{- range where .Site.Pages ".Params.exclude_search" "!=" true -}}
{{- $.Scratch.Add "index" (dict "title" .Title "ref" .Permalink "body" .Plain "excerpt" .Summary) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

The main change I made here was in the range where statement. I decided to add a exclude_search parameter to my Front Matter. Setting this to true, will exclude that page from search results.

A second change I made was to include some extra fields in the JSON file that that template will create1. It adds the body of the page, with HTML stripped out, so the content can be fully searched. It also adds the Summary for the content that can be used when displaying search results.

For example, the Front Matter on the “Thank You” page from my contact form, which I wouldn’t want to appear in search results, looks like this:

---
title: "Thank you"
date: 1999-01-01
layout: page
exclude_search: true
---

After setting that up, building your site by running hugo will produce an index.json file in the root of your site, that will be filled with a JSON formatted document of your posts/pages/etc.

From here, we can move onto the actual Lunr implementation.

I’ll try to boil this code down to just the most pertinent information2 that should apply for most implementations. I have some customizations in mine that are particular to my site that aren’t included here. On my search page, I include the Lunr library from a CDN and my sites search code. Here is what my entire search.md file looks like so you can see the search input field as well.

---
title: "Search"
date: 2018-03-27T16:16:11-04:00
layout: page
exclude_search: true
---
<script src="https://unpkg.com/lunr/lunr.js"></script>
<script src="/search.js"></script>

<div>
  <input id="search-input" type="text" placeholder="What are you looking for?" name="search-input" class="form-control">
</div>
<div id="search-results" class="container"></div>

Here’s some explanation of the search.js file which is placed in the /static/ directory.

We start by setting up some global variables:

var idx = null;         // Lunr index
var resultDetails = []; // Will hold the data for the search results (titles and summaries)
var $searchResults;     // The element on the page holding search results
var $searchInput;       // The search box element

Now we set up the onLoad event:

window.onload = function () {
  // Set up for an Ajax call to request the JSON data file that is created by
  // Hugo's build process, with the template we added above
  var request = new XMLHttpRequest();
  var query = '';

  // Get dom objects for the elements we'll be interacting with
  $searchResults = document.getElementById('search-results');
  $searchInput   = document.getElementById('search-input');

  request.overrideMimeType("application/json");
  request.open("GET", "/index.json", true); // Request the JSON file created during build
  request.onload = function() {
    if (request.status >= 200 && request.status < 400) {
      // Success response received in requesting the index.json file
      var documents = JSON.parse(request.responseText);

      // Build the index so Lunr can search it.  The `ref` field will hold the URL
      // to the page/post.  title, excerpt, and body will be fields searched.
      idx = lunr(function () {
        this.ref('ref');
        this.field('title');
        this.field('excerpt');
        this.field('body');

        // Loop through all the items in the JSON file and add them to the index
        // so they can be searched.
        documents.forEach(function(doc) {
            this.add(doc);
            resultDetails[doc.ref] = {
              'title': doc.title,
              'excerpt': doc.excerpt,
            };
        }, this);
      });
    } else {
      $searchResults.innerHTML = 'Error loading search results';
    }
  };

  request.onerror = function() {
    $searchResults.innerHTML = 'Error loading search results';
  };

  // Send the request to load the JSON
  request.send();

  // Register handler for the search input field
  registerSearchHandler();
};

We need to perform searches when someone types something in the search box, so here is the handler. Whenever there is typing in the search input field, a search will be performed by Lunr:

function registerSearchHandler() {
  $searchInput.oninput = function(event) {
    var query = event.target.value;
    var results = search(query);  // Perform the search

    // Render search results
    renderSearchResults(results);

    // Remove search results if the user empties the search phrase input field
    if ($searchInput.value == '') {
      $searchResults.innerHTML = '';
    }
  }
}

Once a user enters a search term, there will be a call to renderSearchResults() to show the results on the page:

function renderSearchResults(results) {
  // Create a list of results
  var ul = document.createElement('ul');
  if (results.length > 0) {
    results.forEach(function(result) {
      // Create result item
      var li = document.createElement('li');
      li.innerHTML = '<a href="' + result.ref + '">' + resultDetails[result.ref].title + '</a><br>' + resultDetails[result.ref].excerpt;
      ul.appendChild(li);
    });

    // Remove any existing content so results aren't continually added as the user types
    while ($searchResults.hasChildNodes()) {
      $searchResults.removeChild(
        $searchResults.lastChild
      );
    }
  } else {
    $searchResults.innerHTML = 'No results found';
  }

  // Render the list
  $searchResults.appendChild(ul);
}

The last missing piece is telling Lunr to perform the search:

function search(query) {
  return idx.search(query);
}

After that, you should be able to perform searches on your site by visiting the search page on your site.

It’s worth noting that if you are trying this locally using Hugo’s local server functionality, you need to remember to build your index.json file by running hugo. I found that if I also had the Hugo server running while I build index.json, then I had to stop the Hugo server and restart it.

I also recommend you read the last ~3 paragraphs of Joseph’s post because while I did deal with the no results message and some mild error handling, he has some good tips for pre-building the search index, which would be needed on a larger site and I’ll have to do here eventually, along with his mention about possibly moving to promises instead of callbacks3.

What I have is working well for me for now though.


  1. It’s worth noting here that by adding these fields, the JSON could grow quite large. I may need to rethink this down the road. ↩︎

  2. If you want to see my actual implementation, you can view my search.js file. ↩︎

  3. Really, you can tell from his code he’s probably a much better JS developer than me, but I ended up rewriting decent chunks to update the Lunr calls and because of that I dropped a lot of his factory work, etc. ↩︎