Build a Search Bar for Your Hugo Blog With a JSON Index and Some Vanilla JS

Published: Mar 6, 2021
Updated: Oct 10, 2022

Note: An improved version of this template is available at https://github.com/zwbetz-gh/hugo-client-side-search-template. It supports fuzzy searching and match highlighting.


My blog has become a personal knowledge base of sorts. As it’s grown, doing the usual Command + F for browser-search doesn’t always cut it. I sometimes need to search for keywords within a blog post, yet only titles are shown on the blog list page. So, that’s my problem, but how to fix it?

Solutions do exist, but they entail connecting to an external search service, or pulling in a large JS framework. I wanted to keep things as close to home as possible and use good ole vanilla JS.

After much consideration, I added a simple search bar. In a nutshell, it uses a JSON index to search against, then re-renders the blog list on each keyup event.

I’m documenting the relevant bits in case it’s useful for someone else. Let’s dive in.

Prerequisites #

Files #

Config #

config.yaml

In your config file, set the output formats for the homepage. Then add search-related params.

# ...

outputs:
  home:
    - HTML
    - RSS
    - JSON

params:
  search: true
  search_minify: false

Blog List Template #

layouts/blog/list.html

This is a fairly normal blog list template, with a few extras:

{{ define "main" }}
  <h1>{{ .Title }}</h1>
  {{ if site.Params.search }}
  <input
    id="search"
    class="form-control"
    type="text"
    aria-label="Case-insensitive search by title, content, or publish date"
    placeholder="Disabled ..."
    disabled>
  <div id="search_form">
    <div class="form-check">
      <input id="enable_search" class="form-check-input" type="checkbox">
      <label class="form-check-label" for="enable_search">
        Enable search
      </label>
    </div>
    <div class="form-check">
      <input id="regex_mode" class="form-check-input" type="checkbox">
      <label class="form-check-label" for="regex_mode">
        Regex mode
      </label>
    </div>
  </div>
  {{ end }}
  <p id="count">
    Count: {{ len .Pages }}
  </p>
  <ul id="list">
    {{ range .Pages.ByPublishDate.Reverse }}
    <li>
      <span>{{ .PublishDate.Format "2006-01-02" }}</span>
      <a href="{{ .RelPermalink }}">{{ .Title }}</a>
    </li>
    {{ end }}
  </ul>
{{ end }}

JSON Index #

layouts/index.json.json

This is part 1 of 2 of the magic. It iterates all blog posts, then creates a list of relevant fields: Title, PublishDateFormatted, and PlainContent. We configure whether to minify the JSON output with the search_minify param.

{{- $blog := slice -}}

{{- range where site.RegularPages.ByPublishDate.Reverse "Section" "==" "blog" -}}
  {{- $item := dict
    "Title" .Title
    "PublishDateFormatted" (.PublishDate.Format "2006-01-02")
    "RelPermalink" .RelPermalink
    "PlainContent" .Plain -}}

  {{- $blog = $blog | append $item -}}
{{- end -}}

{{- $object := dict "blog" $blog -}}

{{- if (eq site.Params.search_minify true) -}}
  {{- $object | jsonify -}}
{{- else -}}
  {{- $jsonifyOptions := dict "indent" "  " -}}
  {{- $object | jsonify $jsonifyOptions -}}
{{- end -}}

JS #

assets/js/search.js

This is part 2 of 2 of the magic. Here’s how it works:

(function () {
  const SEARCH_ID = 'search';
  const ENABLE_SEARCH_ID = 'enable_search';
  const REGEX_MODE_ID = 'regex_mode';
  const COUNT_ID = 'count';
  const LIST_ID = 'list';

  let list = null;
  let filteredList = null;

  const logPerformance = (work, startTime, endTime) => {
    const duration = (endTime - startTime).toFixed(2);
    console.log(`${work} took ${duration} ms`);
  };

  const getSearchEl = () => document.getElementById(SEARCH_ID);
  const getEnableSearchEl = () => document.getElementById(ENABLE_SEARCH_ID);
  const getRegexModeEl = () => document.getElementById(REGEX_MODE_ID);
  const getCountEl = () => document.getElementById(COUNT_ID);
  const getListEl = () => document.getElementById(LIST_ID);

  const disableSearchEl = placeholder => {
    getSearchEl().disabled = true;
    getSearchEl().placeholder = placeholder;
  };

  const enableSearchEl = () => {
    getSearchEl().disabled = false;
    getSearchEl().placeholder =
      'Case-insensitive search by title, content, or publish date';
  };

  const disableRegexModeEl = () => {
    getRegexModeEl().disabled = true;
  };

  const enableRegexModeEl = () => {
    getRegexModeEl().disabled = false;
  };

  const fetchJsonIndex = () => {
    const startTime = performance.now();
    disableSearchEl('Loading ...');
    const url = `${window.location.origin}/index.json`;
    fetch(url)
      .then(response => response.json())
      .then(data => {
        list = data.blog;
        filteredList = data.blog;
        enableSearchEl();
        logPerformance('fetchJsonIndex', startTime, performance.now());
      })
      .catch(error =>
        console.error(`Failed to fetch JSON index: ${error.message}`)
      );
  };

  const filterList = regexMode => {
    const regexQuery = new RegExp(getSearchEl().value, 'i');
    const query = getSearchEl().value.toUpperCase();
    filteredList = list.filter(item => {
      const title = item.Title.toUpperCase();
      const content = item.PlainContent.toUpperCase();
      const publishDate = item.PublishDateFormatted.toUpperCase();
      if (regexMode) {
        return (
          regexQuery.test(title) ||
          regexQuery.test(content) ||
          regexQuery.test(publishDate)
        );
      } else {
        return (
          title.includes(query) ||
          content.includes(query) ||
          publishDate.includes(query)
        );
      }
    });
  };

  const renderCount = () => {
    const count = `Count: ${filteredList.length}`;
    getCountEl().textContent = count;
  };

  const renderList = () => {
    const newList = document.createElement('ul');
    newList.id = LIST_ID;

    filteredList.forEach(item => {
      const li = document.createElement('li');

      const publishDate = document.createElement('span');
      publishDate.textContent = item.PublishDateFormatted;

      const titleLink = document.createElement('a');
      titleLink.href = item.RelPermalink;
      titleLink.textContent = item.Title;

      li.appendChild(publishDate);
      li.appendChild(document.createTextNode(' '));
      li.appendChild(titleLink);

      newList.appendChild(li);
    });

    const oldList = getListEl();
    oldList.replaceWith(newList);
  };

  const handleSearchEvent = () => {
    const startTime = performance.now();
    const regexMode = getRegexModeEl().checked;
    filterList(regexMode);
    renderCount();
    renderList();
    logPerformance('handleSearchEvent', startTime, performance.now());
  };

  const handleEnableSearchEvent = () => {
    if (getEnableSearchEl().checked) {
      fetchJsonIndex();
      enableRegexModeEl();
    } else {
      disableSearchEl('Disabled ...');
      disableRegexModeEl();
    }
  };

  const addEventListeners = () => {
    getEnableSearchEl().addEventListener('change', handleEnableSearchEvent);
    getSearchEl().addEventListener('keyup', handleSearchEvent);
    getRegexModeEl().addEventListener('change', handleSearchEvent);
  };

  const main = () => {
    if (getSearchEl()) {
      addEventListeners();
    }
  };

  main();
})();

Import the JS #

Import the JS on all pages. This is usually done in your layouts/_default/baseof.html template. Sample

{{ if site.Params.search }}
  {{ $searchJs := resources.Get "js/search.js"
    | resources.ExecuteAsTemplate "js/search.js" .
    | fingerprint }}
  <script src="{{ $searchJs.RelPermalink }}"></script>
{{ end }}

References #