How to Setup Search on Hugo

Setting up search on your static site built using Hugo or Jekyll is not as difficult as it sounds. If your static site is hosted on GitHub pages (which mine currently is), then you will not be able to use php or any server side scripting. Therefore, we must rely on a pure javascript solution.

Using Lunr.js

Lunr is a popular library used to parse through data and return the most accurate match based on a search query. We will use it to provide the core search functionality on our site. In order to use lunr, we need to feed it data about every post on our site (title, tags, description). We will use an NPM package that will auto-generate this for us.

Step 1:

Make sure you have Nodejs installed (skip to the bottom of this article for an alternative method). Then start by initializing a new project in the root of your hugo site directory:

npm init -y

You should get out an empty package.json. Now let’s install the hugo-lunr package. This will allow you to easily run a script that will traverse your post and output a JSON file filled with the info that we can provide to lunr.js.

npm install hugo-lunr

You will not see the package listed under your dependencies in package.json.

Step 2:

Inside of the scripts property, create a new script with the following:

"scripts": {
       "index": "hugo-lunr" 
}

When you run this script, it will automatically look in the content directory of your hugo site and traverse though each post and output a json in public/lunr.json.

But wait, my posts are not in TOML

If you are like me, and prefer to have your posts written in YAML, then you will need to convert them to TOML before hugo-lunr can process them. Modify your script to have the following:

"scripts": {
       "index": "hugo convert toTOML -o ./converted && hugo-lunr -i \"converted/**\" -o static/js/index.json && rmdir /s /q \"converted\"",
}

Step 3:

Now it’s time to create an actual page for search. Create a new html file called search.html in your content/page directory. In this file, we will setup the html we will want to display for searches to be visible.

    <div class="input-group">
      <input type="text" class="form-control" id="search-field" placeholder="Search for..." />
      <span class="input-group-btn">
        <button class="btn btn-default" type="button" id="search-button">Search</button>
      </span>
    </div>
    <!-- /input-group -->
    <p style="text-align:right; font-size:10px;" id="found"></p>
    <hr/>
    <div id="results"></div>
  </div>

Step 4:

Create a javascript file called search.js in your static/js directory. Put the following inside of that file:

if ((window.location.pathname == "/page/search/")) {
  var lunrIndex, allPosts;
  fetch("/js/index.json")
    .then(response => {
      return response.json();
    })
    .then(response => {
      allPosts = response;
      lunrIndex = lunr(function() {
        this.field("title", {
          boost: 10
        });
        this.field("tags", {
          boost: 5
        });

        // ref is the result item identifier (I chose the page URL)
        this.ref("uri");
        for (var i = 0; i < response.length; ++i) {
          this.add(response[i]);
        }
      });
    })
    .catch(error => {
      console.error(error);
    });

  document.getElementById("search-button").onclick = function() {
    search();
  };

  function search() {
    document.getElementById("results").innerHTML = "";
    let query = document.getElementById("search-field").value;
    let results = lunrIndex.search(query).map(result => {
      return allPosts.filter(page => {
        return page.uri === result.ref && result.score > 4;
      })[0];
    });
    let totalResults;
    results = results.filter(p => {
      if (p) {
        return true;
      }
    });
    document.createElement("h1");
    for (let i = 0; i < 20 && i < results.length; i++) {
      let header = document.createElement("h2");
      let anchor = document.createElement("a");
      anchor.setAttribute("href", results[i].uri);
      anchor.innerText = results[i].title;
      header.appendChild(anchor);
      document.getElementById("results").appendChild(header);
      document.getElementById("found").innerText = `Found ${
        results.length
      } results - showing ${i + 1}`;
    }
  }
  document
    .getElementById("search-field")
    .addEventListener("keydown", function(event) {
      if (event.key === "Enter") {
        event.preventDefault();
        search();
      }
    });
}

Let’s break down what’s happening:


if ((window.location.pathname == "/page/search/")) {

First we check to make sure that we are only executing this script on the search page because we will be queuing this in the footer (which is present on all pages)

 var lunrIndex, allPosts;
  fetch("/js/index.json")
    .then(response => {
      return response.json();
    })
    .then(response => {
      allPosts = response;
      lunrIndex = lunr(function() {
        this.field("title", {
          boost: 10
        });
        this.field("tags", {
          boost: 5
        });

        // ref is the result item identifier (I chose the page URL)
        this.ref("uri");
        for (var i = 0; i < response.length; ++i) {
          this.add(response[i]);
        }
      });
    })
    .catch(error => {
      console.error(error);
    });

Now we are making a HTTP request to get the JSON generated by hug-lunr. Yes, this is using fetch which might not work on old IE, but I really don’t care about that.

Once we have the response we are initializing lunr. This fields that we pass into lunr must match the fields present in the JSON file. Then we loop through the JSON array and add each object into lunr.

 document.getElementById("search-button").onclick = function() {
    search();
  };

The onclick handler for the search button.

function search() {
    document.getElementById("results").innerHTML = "";
    let query = document.getElementById("search-field").value;
    let results = lunrIndex.search(query).map(result => {
      return allPosts.filter(page => {
        return page.uri === result.ref && result.score > 4;
      })[0];
    });
    let totalResults;
    results = results.filter(p => {
      if (p) {
        return true;
      }
    });

When we call search, we will call the search function on lunr with the parameters from the input. Lunr is going to return an array containing a score and URL. We are using the filter function to match that with the items from the response (saved in allposts) so that we can grab the title as well.

I am doing another filter at the end to remove all the null elements. This part could probably be more efficiently done.

 document.createElement("h1");
    for (let i = 0; i < 20 && i < results.length; i++) {
      let header = document.createElement("h2");
      let anchor = document.createElement("a");
      anchor.setAttribute("href", results[i].uri);
      anchor.innerText = results[i].title;
      header.appendChild(anchor);
      document.getElementById("results").appendChild(header);
      document.getElementById("found").innerText = `Found ${
        results.length
      } results - showing ${i + 1}`;
    }
  }
  document
    .getElementById("search-field")
    .addEventListener("keydown", function(event) {
      if (event.key === "Enter") {
        event.preventDefault();
        search();
      }
    });

Writing the output to the DOM.

Step 5:

Finally, let’s add the lunr.js library and our search.js into the footer. You can create a HTML file called footer in layouts/partials or modify your theme’s footer.

<script
  src="https://cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.5/lunr.min.js"
  integrity="sha256-uScRgGrInD2VnPNpjmlQtB2XRVLczyyZvrTkYi+e31U="
  crossorigin="anonymous"
></script>
<script src="{{ "js/search.js" | absURL }}"></script>

That’s it. Now you should have a really fast search available on your hugo static website.

Update: Without Nodejs

I have slightly changed the method to get search working without setting up Nodejs and running the script to generate the index.json each time. Currently, I am now fetching my sitemap.xml and adding the individual values into an array for lunr to read. It still works the same, but requires JQuery to parse the XML values. Here’s the code change:

if ((window.location.pathname == "/page/search/")) {
  var lunrIndex;
  var allPosts = [];
  fetch("/index.xml")
    .then(response => {
      return response.text()
    })
    .then(response => {
      xmlDoc = $.parseXML( response ),
      $xml = $( xmlDoc ),
        $article = $xml.find("item").each(function (i, e) {
          $title = $(this).find('title').text();
          $link = $(this).find('link').text();
          $desc = $(this).find('description').text();
          allPosts.push({title: $title, uri: $link, description: $desc})

        });
      lunrIndex = lunr(function() {
        this.field("title", {
          boost: 10
        });
        this.field("description", {
          boost: 5
        });

        // ref is the result item identifier (I chose the page URL)
        this.ref("uri");
        for (var i = 0; i < allPosts.length; ++i) {
          this.add(allPosts[i]);
        }
      });
    })
    .catch(error => {
      console.error(error);
    });