Page search without a backend, third parties and zero dependencies

It’s fair to say I’m in love with Hugo; and one feature I really wanted to add to my blog was a simple search form for posts and pages.

A search setup we’re all familair with usually involves:

  1. a http request
  2. a web-server
  3. a database
  4. a http response
  5. and eventually an update of the DOM

The obvious benefits of this is you can build a complex a search behaviour as you want, querying terms across datasets or perhaps querying a dynamic datafeed. Lets be honest though; for 99% of the Hugo blogs out there (and most websites), a simple search with %like% behaviour that can look through the title of every post or page within that site is probably going to give the users what they want.

Whilst my Hugo site doesn’t have a backend, a simple page search doesn’t really need one. Hugo has access to everything it needs before the site is built, and so we just need to create a reference to this to be handed over to the client.

The search form on this blog was built using the same code and logic that I’m going to explain in this post.

Preparing the search data

Hugo has all the information we need at build time and we can access this by starting with a loop through .Site.AllPages

This will give us access to an array of all pages, regardless of translation within our hugo site. It will give us posts under all taxonomies, and all listing pages that will be available at build time.

Read about Hugo site variables.

In a template file, ideally baseof.html for my blog but you can pick any layout file, and probably near the closing body tag, add an inline <script> element.

<script>
{{- $pages := slice -}}
  {{- range .Site.AllPages -}} 
    {{- if not .Draft -}}
      {{- $page := dict "name" .Name "url" .Permalink -}}
      {{- $pages = append $page $pages -}}
    {{- end -}}
  {{- end -}} 

const SITE_DATA = Object.freeze({
    pages: {{ jsonify $pages }},
});
</script>

So whats going on here is:

  1. We create a new array and call it $pages

    {{- $pages := slice -}}

  2. We loop through the pages of the site

    {{- range .Site.AllPages -}} 

  3. What was that advert about things doing what they say on the tin?

    {{- if not .Draft -}}

  4. We create a new variable called $page and assign it the value of a dictionary which holds the name and permalink of the page, we append this $page to our $pages array.

    {{- $page := dict "name" .Name "url" .Permalink -}}
    {{- $pages = append $page $pages -}}

  5. Finally we output a jsonified version of our $pages array onto our template ready to work with.

const SITE_DATA = Object.freeze({
    pages: {{ jsonify $pages }},
});

Preparing the search data by only passing back the name of the post means tags, categories, html meta and post content will not be searchable but i personally don’t really need that complex of a search. If you did want this, just add those params to each dictionary thats built within the loop.

Building a simple form

Okay so this bit is a bit more boilerplate-like: you’ll need a simple search form:

<div id="search">
    <form id="search-form" class="m-b-m">
        <div class="m-b-s">
            <label for="query">Search:</label>
            <input type="search" name="query" id="query" placeholder="Search for the name of a post, tag or category...">
        </div>
        <input type="submit" value="Submit" />
    </form>
    <div id="search-results"></div>
</div>

I’ve personally added this to a sidebar that gets used on both my list and single templates, but realistically you can add this anywhere you like.

Listing results

preamble: Okay, now its into a world of JavaScript. Note the last few lines of code form the data we output onto our template.

const SITE_DATA = Object.freeze({
    pages: {{ jsonify $pages }},
});

So whats going on here, is we’re creating a constant called SITE_DATA and we’re using the shiny newish (at the time of writing) Object.freeze syntax.

This isn’t a MUST HAVE as such, but if we think about what this syntax does for us then hopefully its clear why we do this.

Hugo sites are static sites, and our site data object should probably reflect this as closely as possible. So by freezing our object its saying to any Javascirpt code we eventually write “hey - what ever happnes on the other side, I don’t want this to change.”

  1. The first thing we’ll want to do is add an event listener to capture the form submit event.
const search = (() => {

    const search = document.querySelector('#search-form');
    const searchResults = document.querySelector('#search-results');

    search.addEventListener('submit', (e) => {
      e.preventDefault(); 
    });

  })();

I recommend for a11y and html form validation that you capture the form submit event and prevent the default broswer behaviour instead of capturing the button click, but it’s up to you.

  1. Okay, so we have our form, and we’re capturing the submit, let’s start building the search:
const search = (() => {

    const search = document.querySelector('#search-form');
    const searchResults = document.querySelector('#search-results');

    search.addEventListener('submit', (e) => {
      e.preventDefault(); 
      searchResults.innerHTML = '';
      const output = document.createElement('div');
      const data = new FormData(e.target);
      const query = data.get('query');
    });

  })();
  • First step here is that we’re emptying out the search results inner html, so we can search multiple times,
  • We create a variable called output and assign it the value of a new HTMLDomElement
  • We create a variable called data and assign it the value of a new FormData object.
  • We create a variable called query and assign it the value of the query form field in our FormData object.
  • The FormData constructor takes the event target - in this case the form as its arguement. see mdn’s notes on using FormData objects.
  1. Great, we have the value we want to search aginst stored in our query variable. Let’s travserse the site-data:
const search = (() => {

    const search = document.querySelector('#search-form');
    const searchResults = document.querySelector('#search-results');

    search.addEventListener('submit', (e) => {
      e.preventDefault(); 
      searchResults.innerHTML = '';
      const output = document.createElement('div');
      const data = new FormData(e.target);
      const query = data.get('query');
      const pages = JSON.parse(SITE_DATA.pages);
      const results = Array.from(pages)?.filter(page => page.name.toLowerCase().includes(query.toLowerCase())) || [];
    });

  })();
  • So the next step is for us to target the data we want to search - for my SITE_DATA setup its in its own key called pages. As we know it’s stringified json at souce, So we create a new variable called pages and we then json parse the pages data into a workable JavaScript array.
  • Now we create a new variable called results and use the array filter mehtod. The filter parameters we check for are:
page => page.name.toLowerCase().includes(query.toLowerCase())

So against each page in the array, we’ll compare the value of the page’s name against the query the user entered as the search term and IF the page’s name includes the query term - return true. Its a good idea to use the .toLowerCase() function when comparing search terms in javascript as string comparisons are case senesitive.

  1. If we have some results, we want to output them: if not, perhaps throw up a notice saying there were no search results.

Let’s define a function to give us some markup for a search result:

 const search = (() => {

    const searchResult = (result) => `<div class="search-result"><a href="${result.url}">${result.name}</a></div>`;
    const search = document.querySelector('#search-form');
    const searchResults = document.querySelector('#search-results');

    search.addEventListener('submit', (e) => {
      e.preventDefault(); 
      searchResults.innerHTML = '';
      const output = document.createElement('div');
      const data = new FormData(e.target);
      const query = data.get('query');
      const pages = JSON.parse(SITE_DATA.pages);
      const results = Array.from(pages)?.filter(page => page.name.toLowerCase().includes(query.toLowerCase())) || [];
    });

  })();

So (now the first line inside the IIFE) - we added a function that will take a search result and give us back some markup.

  1. Now lets use that function to either generate results or show a notice for empty result sets.
const search = (() => {

    const searchResult = (result) => `<div class="search-result"><a href="${result.url}">${result.name}</a></div>`;
    const search = document.querySelector('#search-form');
    const searchResults = document.querySelector('#search-results');

    search.addEventListener('submit', (e) => {
      e.preventDefault(); 
      searchResults.innerHTML = '';
      const output = document.createElement('div');
      const data = new FormData(e.target);
      const query = data.get('query');
      const pages = JSON.parse(SITE_DATA.pages);
      const results = Array.from(pages)?.filter(page => page.name.toLowerCase().includes(query.toLowerCase())) || [];
      if (results.length > 0 && query.length > 0) {
        const markup = results.map(result => searchResult(result));
        output.innerHTML = markup.join('');
        searchResults.appendChild(output);
      } else {
        output.innerHTML = `<p class="empty-notice">No results found for ${query}</p>`;
        searchResults.appendChild(output);
      }
    });

  })();