See the code for this project here: hugo-jsonify-data.

One of the features that makes Hugo so versatile is how it converts data into content. While this is an “easy” task for many developers, Hugo also builds that content into an easily managed and hosted static website - not “easy” for a typical CMS.

You can easily plunk your Hugo site into an S3 bucket, webserving Linux server, or static-specific hosting service like Netlify, Cloudflare Pages, or Vercel. Conventional CMSes require, at the minumum, a virtual host machine, database, webserver, and memory/CPU resources to run everything.

A website that needs to convey technical specifications and data can benefit from Hugo’s transformations, including building an API!

While I’m not going to cover building an API, I am going to talk about a specific use case of using content files to generate JSON that can be used elsewhere in the site.

The Use Case: Alpine JS Filtering of Hugo Content Pages

Let’s lay out the spec and requirements:

  • I have a bunch of machine-generated pages that are full of technical data.
  • A user needs to be able to quickly filter that data with multiple parameters.
  • Once the user has narrowed their search, they can click an item to go to its content page.
  • We need to display an optimized, Hugo-processed image for each item.
  • Alpine JS will do the filtering and displaying.
  • Alpine JS needs to be fed a JSON object.

Can We Use a Hugo-Generated API?

The answer is “No”.

Using processed images throws a bit of a wrench in the works. Hugo has excellent, fast, option-loaded image processing support. But actually delivering these images on the home page I was building took a little tinkering.

I explored two approaches:

Approach 1: Build an API

You can build an API in Hugo, but this wouldn’t work for a few reasons. Let’s take a look:

I quickly built out an API, very similar to the one described by Regis Philbert. Then, I tried to pull from my API to populate my data. Technically, I was outputting each page as HTML and as JSON using Hugo’s output formats.

Output formats are very powerful, and can allow you to create lots of different types of content for specific pages, based on the data in your page.

To set this up, I configured output formats in my hugo.toml, then added templates for HTML and JSON.

Once this was configured, I queried for the pages from which I wanted the JSON output format:

{{ $pages := where .Site.RegularPages "Section" "books" }}
{{ range $pages }}
  {{ with .OutputFormats.Get "json" }}
    {{ .RelPermalink }} 
  {{ end }}
{{ end }}

From here, I used the permalink to try to load the actual JSON, but ran into a problem:

You can’t grab the content of an output format in your template, only the link to it.

I had suspected this was the case, and it makes sense.

If you’re trying to build Page1 from the JSON content of Page2, how would Hugo know to build Page2 and its JSON content first?

Well, it doesn’t! So you’ll most likely get a build failure.

I spent a bit of time chasing this down because a forum thread on the Hugo Discourse seemed to strongly indicate that this would work. However, I later found a comment by bep (the lead dev on Hugo) stating that it’s not possible to get content of another output format into a template.

The API solution, while fun to play around with, would also be a lot more work to build out than my second approach. You need multiple API/JSON templates.

Approach 2: Build JSON with the jsonify template tag

This solution was incredibly simple!

Using the jsonify template tag in Hugo allows you to take a collection of pages (or other things), process them however you want (this is key!), and return that data as JSON.

This solution meant that I could process images with Hugo, and return HTML in JSON to be used by Alpine jS.

Let’s take a look at a simple version of this.

Building JSON with the jsonify Template Function

We’re going to run through a pretty easy example of how this works. We need to start with creating some data, so let’s put some stuff in the content directory.

Create the Content/Data

In your content dir, create a directory and files that look like this:

content
├── books
│   ├── book-1
│   │   └── index.md
│   ├── book-2
│   │   └── index.md
│   └── _index.md
└── _index.md

The index.md files can be empty, but they’ll tell Hugo that “books” is a “section”.

If you want, from your Hugo root, run these commands to create the files and directories:

touch content/_index.md
mkdir -p content/books/{book-1,book-2}
touch content/books/_index.md
touch content/books/{book-1,book-2}/index.md

Now, you can put lots of things in your book files, but to start we’ll keep it simple. Add this content:

content/books/book-1/index.md

---
title: A Great Adventure
price: 5
rating: 9
id: 1
---

content/books/book-2/index.md

---
title: A Good Adventure
price: 4
rating: 6
id: 2
---

Great! We have our data!

Create a single.html Template

Besides using the book data on the index page, we also want to look at an individual book. To do that we need to create a single.html template:

layouts/books/single.html

{{ define "main" }}
  {{ .Title }}
  <br>
  {{ .Params.price }}
  <br>
  {{ .Params.rating }}
  <br>
  {{ .Content }}
{{ end }}

Pull the Book Data into the Index Page

We want to use our book data with Alpine JS on our index page, so add an index page to your layouts like this:

layouts/index.html

{{ define "main" }}

  <script src="//unpkg.com/alpinejs" defer></script>

  {{ $data := slice }}
  {{ range $index, $page := (where .Site.RegularPages "Section" "books") }}
    {{ $data = $data | append (dict
    "title" .Title
    "price" .Params.price
    "rating" .Params.rating
    "ref" .Permalink
    )
    }}
  {{ end }}

  {{ $data }} {{/* optional */}}

  {{ $data = sort $data "price" "asc" }} {{/* optional */}}

  {{ $data = ($data | jsonify) }}

  <script>
    const bookData = JSON.parse({{ $data }})
    console.log(bookData)
  </script>

  <div x-data="bookData">
    <template x-for="book in bookData" :key="book.title">
      <h2 x-text="book.title"></h2>
    </template>
  </div>
{{ end }}

The Breakdown

That might be a lot to digest, so let’s take a look at a few lines.

{{ $data := slice }}

The first thing we do, is initialize the $data variable to an empty slice. A slice in Go is just a list or array. So, we’re just saying that $data is a slice so that we can do “slice things” to it later.

{{ range $index, $page := (where .Site.RegularPages "Section" "books") }}
  {{/* other stuff happens */}}
{{ end }}

Next, we query our Hugo site for any page that is in the section “books” (this excludes the books list or index page). I’ve included the $index variable, which is often useful when you need to know the index of the item in the slice you’re iterating, but it’s not really important here.

Then, we iterate over the loop to do stuff to each book. That stuff is…

{{ $data = $data | append (dict
  "title" .Title
  "price" .Params.price
  "rating" .Params.rating
  "ref" .Permalink
)}}

There are two parts to this section, the append and the dict. Let’s start with the dict:

We’re using another data stucture, dict, to assemble key-value pairs for each book, like title and price.

Then, we take each dict and append it to our $data variable. The end result is that our $data will be a list of dicts that each contain data about a single book.

{{ $data }} {{/* optional */}}

Since this all might be a little confusing, this line will simply print what we just assembled: a list of books with keys and values. This is an easy way to see if we’re producing the correct output!

{{ $data = sort $data "price" "asc" }} {{/* optional */}}

This is another optional line, but I included it because this is much faster than sorting in Javascript!

If you need to initialize your data in a certain order, this is fast for you (because it’s written in Go), and fast for your user (because they don’t wait for your data to sort on page load). Plus, this is shorter than writing a Javascript sort function. Win-win-win!

Finally:

{{ $data = ($data | jsonify) }}

This is the Hugo templating finale! Here, we tell Hugo to turn our slice of dicts ($data) into JSON, then assign it back to the $data variable again. Boom, we’ve got JSON data.

So, now let’s take our data and do something in HTML/Javascript:

<script>
  const bookData = JSON.parse({{ $data }})
  console.log(bookData)
</script>

<div x-data="bookData">
  <template x-for="book in bookData" :key="book.id">
    <div>
      <h2 x-text="book.title"></h2>
      <p x-text="book.price"></p>
      <p x-text="book.rating"></p>
    </div>
  </template>
</div>

First, we need to parse the Hugo-built JSON:

const bookData = JSON.parse({{ $data }})
console.log(bookData) // optional

You can verify that what the data looks like checking out the array in your browser console.

From there, we’re using Alpine JS to iterate over our JSON data. We can do whatever we want to our JSON data.

This is useful, but we haven’t gotten to the cool part yet!

The Cool Part - Advanced Processing

Let’s go back to this section of code:

{{ $data = $data | append (dict
  "title" .Title
  "price" .Params.price
  "rating" .Params.rating
  "ref" .Permalink
)}}

If you didn’t notice, we can stick any key-value pair in here that we want - this is just a Hugo template!

Let’s Add Visual Ratings with Asterisks

Let’s output asterisks for our rating instead of a number. We’ll print the number of asterisks that corresponds to the rating number (i.e. a rating of “3” equals “***”).

{{/* Make a string of asterisks as long as the rating */}}
{{ $astRating := slice }} 
  {{ range seq .Params.rating }}
    {{ $astRating = $astRating | append "*" }}
  {{ end }}
{{ $astRating = delimit $astRating "" }}

{{/* Add the string to our dict */}}
{{ $data = $data | append (dict
  "title" .Title
  "price" .Params.price
  "rating" .Params.rating
  "astRating" $astRating 
  "ref" .Permalink
)}}

…then we’ll use this value in our template:

<div x-data="bookData">
  <template x-for="book in bookData" :key="book.title">
    <div>
      <h2 x-text="book.title"></h2>
      <p>$<span x-text="book.price"></span></p>
      <p x-text="book.rating"></p>
      <p x-text="book.astRating"></p>
    </div>
  </template>
</div>

What this means:

Basically, any processing you can do with Hugo in a normal template, you can also do in a JSON template, then deliver that result to where you need it.

Advanced Image Processing That We Can Use in JSON!

Hugo image processing support is excellent. This article was born because I wanted to use Hugo’s image processing, but needed the dynamic capabilities afforded by Alpine JS and JSON in the search page.

So, let’s do some setup.

To keep it simple, I’m just going to generate images for each book using imagemagick by running these commands:

convert -size 300x300 xc:red content/books/book-1/image.jpg
convert -size 300x300 xc:blue content/books/book-2/image.jpg

You can stick any old images in there, but just make sure they are named image.jpg for this exercise.

Now our content folder should look like this:

content
├── books
│   ├── book-1
│   │   ├── image.jpg
│   │   └── index.md
│   ├── book-2
│   │   ├── image.jpg
│   │   └── index.md
│   └── _index.md
└── _index.md

Let’s add a text overlay to our images

Much like we did with our ratings, we’re going to grab the images and do some Hugo processing before adding it to our dict.

We’ll modify what happens inside the range loop to overlay the title of our book on the image:

{{ $text := .Title }}
{{ $overlay := images.Text $text }}
{{ $imgHTML := "" }}
{{ with .Resources.Get "image.jpg" }}
  {{ with . | images.Filter $overlay }}
    {{ $permalink := .RelPermalink }}
    {{ $width := .Width }}
    {{ $height := .Height }}
    {{ $alt := $text }}
    {{ $imgHTML = printf "<img src='%s' width='%d' height='%d' alt='%s'>" $permalink $width $height $alt }}
  {{ end }}
{{ end }}

We’re using Hugo’s image processing “Text” filter to add text to the image. We set the $imgHTML variable to an HTML string that we build with printf.

Next we add $imgHTML to our dict:

{{ $data = $data | append (dict
  "title" .Title
  "price" .Params.price
  "rating" .Params.rating
  "astRating" $astRating 
  "ref" .Permalink
  "img" $imgHTML
)
}}

Then we use Alpine JS to display it, using x-html:

<div x-data="bookData">
  <template x-for="book in bookData" :key="book.title">
    <div style="margin-top: 2em; padding: 2em; border: 1px gray solid;">
      <a :href="book.ref"><h2 x-text="book.title"></h2></a>
      <p>$<span x-text="book.price"></span></p>
      <p><span x-text="book.rating"></span>/10 stars</p>
      <span x-text="book.astRating"></span>
      <div x-html="book.img"></div>
    </div>
  </template>
</div>

And there we go!

We now have a very dynamic front end because of Alpine JS, powered by JSON data built by Hugo. It’s pretty great.

Where to Next?

If we’d just output a bunch of Hugo static pages, there is only so much we could do with them.

While it would make sense to create author or subject pages, what if you want users to sort by rating. How about reverse rating? Do you create pages for each of those searches? (And actually, there are SEO benefits to making pages like this for certain searches.)

What if someone wants the highest rated books under $15 written by an author? At some point creating multiple page permutations starts becoming tedious.

Instead, you can use the JSON data we’ve created to let users dynamically sort, filter, and search.

This is a very powerful way to take advantage of the data that is already in your content files!