Build a JSON API With Hugo's Custom Output Formats

Build a JSON API With Hugo's Custom Output Formats

As developers, we love open and accessible data. For example, you may want to use your local transit data for a mobile app, or maybe you want a service like Zapier or IFTTT to send you an email every time the weather forecast calls for rain. If you’re a super geek like @bdougieyo, you’ve built an app that tells you when to leave work in order to avoid traffic from your local baseball team’s home games (from his excellent talk here).

In order to make data that is “machine friendly” like this, we can expose it with a RESTful API. Normally, the quickest way to bootstrap an API like this would be to start with a popular framework like Flask or Ruby on Rails, spin up a webserver, and connect a database. We can achieve something even simpler, however, by creating a read-only API with a static site generator. In this article, we’ll use Hugo to build a JSON API using its Custom Output Formats feature.

Get Started With Hugo JSON API

Import the Hugo Project

The Hugo JSON API can easily be edited in Forestry but when you preview please make sure to add index.json (e.g. https://the-site-id.preview.forestry.io/players/frank-j-robinson/index.json)

Planning the Interface

API stands for Application Programming Interface. When it comes to building an API, designing a good Interface is as important as the underlying software. Other developers will use this interface for programming their applications. See what I did there?

A RESTful API is a slightly relaxed interpretation of the REST paradigm for API design: its goal is to use the built-in semantics of the HTTP standard to define the interface.

One of the primary ways an API can be RESTful is by using HTTP verbs to denote the intent of the request. These verbs include GET, PUT, POST, and DELETE, and correspond to different actions that should be performed on the requested resource. Since our static site can’t run any backend code, it can’t do anything meaningful with a PUT, POST, or DELETE request: we will only respond to GET. This is why our API is read-only.

Our sample project will provide data for sports teams and players of a fictional High School.

We’ll start with three endpoints where we’ll focus on returning listed data and individual player data:

Endpoint Resource
GET / Lists everything
GET /players Lists players
GET /players/{:slug} Data for a single player

Later on, we will add teams and a sports taxonomy, adding three new endpoints.

Endpoint Resource
GET /teams Lists teams
GET /team/{:slug} Team data and players
GET /sports/{:sport} List of players and teams for the specified sport

Setting up the Site

Download the sample project to hit the ground running.

Start by creating a new Hugo site (see our Up & Running with Hugo series or Hugo’s quick start guide).

In our content directory, we’ll create a players folder and add some players to it.

├── my-hugo-project/       # The root of our Hugo project
|   ├── content/           # Where all site content is stored 
|   |   ├── players/       # Your players section  
|   |   |   ├── frank-j-robinson.md
|   |   |   └── jody-garland.md

Our player markdown will look something like this:

---
title: "Frank J. Robinson"
date: 2018-02-05
age: 17
emergency_contact: +1 (555) 555 5555
year: junior
---

Frank, our number one second baseman!

Output Formats

By default, Hugo will build an HTML version of each page. It will also build an XML version of your home page, using its built-in RSS Output Format.

In our case, all we need to output is a JSON formatted file for each page, section and the home page.

The JSON output format is also built-in, all we need is to open our config.toml file and tell Hugo where to use it.

[outputs]
  page = ["json"] # A player
  section = ["json"] # All players
  home = ["json"] # Everything

Next, we need to create a template for Hugo to build this format. When creating Hugo template files for a custom output format, we need to follow a specific naming convention:

{pageKind}.{outputFormatName}.{extension}

To build our single players page, Hugo will look for

layout/_default/single.json.json 

To build our section list page and our home page, Hugo will look for

layouts/_default/list.json.json

Templating

We will use custom templates to build the API responses for our first 3 endpoints. Each of them will use a consistent « player » object. We will need to create templates to support the following views:

  • A list view for our players section and home page.
  • A single item view for each player’s details.

To achieve this, we’ll create the following templates in /layouts/_default/:

  • baseof.json.json — A base template for our API response.
  • item.json.json — The single item object, which will be reused in all of our templates, every time we need to output a player. In Hugo we call this a Content View, but for our API I see it more like a transformer.
  • single.json.json — The response output for a single player
  • list.json.json — The response output for a list of players

Base templates are top-level templates that are the starting point for all pages in Hugo. They are usually full of HTML where we lodge in different blocks, but our JSON base will be much simpler. Our API response should contain a data object on success and an error object on error. So we will set up the data object and use a block for the individual response.

Our simple baseof.json:

{
    "data" : {{ block "response" .}}{{ end }}
}

item.json.json

The Hugo docs suggest you call this template li.json, but for clarity we’ll call it item.json. This is the one item we’ll load or render every time we want to output information for a player.

We want the output of a player object to look like this:

{
    "name": "Frank J. Robinson",
    "contact" : "+1 555 555-555",
    "permalink" : "https://school.api/players/frank-j-robinson/index.json"
}

To achieve this, add the following to item.json.json:

{
    "name": "{{ .Title }}",
    "contact" : "{{ .Params.emergency_contact }}",
    "permalink" : "{{ .Permalink }}"
}

That’s all there is to it!

single.json.json

We just have to render our item.json.json inside our response block. To do so, add this to single.json.json.

{{ define "response" }} {{ .Render "item" }} {{ end }}

Now you should be able to visit the API endpoint for a single player (http://localhost:1313/players/frank-j-robinson/index.json) and get a response.

While developing your API, you will need to run the hugo serve command with the --disableFastRender flag in order for Hugo to automatically rebuild these JSON files.

Also, note that Hugo’s built-in webserver can’t live reload non-HTML documents, so you will need to manually reload the browser to see your changes.

{
    "data": {
        "name": "Frank J. Robinson",
        "contact": "+1 (555) 555 5555",
        "permalink": "https://school.api/players/frank-j-robinson/index.json"
    }
}

list.json.json

For our list template, we need to iterate over the current section’s pages and render our item.json.json template for each one inside the response block.

{{ define "response" }}
[
    {{ range $index, $e := .Data.Pages }}
    {{ if $index }}, {{ end }}{{ .Render "item" }}
    {{ end }}
]
{{ end }}

We’re using the $index variable here to prevent our template from outputting a comma after the last item in our array, since that would result in invalid JSON.

Now your players API endpoint (http://localhost:1313/players/index.json) should return a list

{
    "data": [
        {
            "name": "Frank J. Robinson",
            "contact": "+1 (555) 555 5555",
            "permalink": "http://localhost:1313/players/frank-j-robinson/index.json"
        },
        {
            "name": "Jody Garland",
            "contact": "+1 (555) 555 5555",
            "permalink": "http://localhost:1313/players/jody-garland/index.json"
        },
        ...
    ]
}

404.json

If the consumer of our API requests a nonexistent resource, it would be nice if we could return a 404 response with an error message in valid JSON. Let’s add a 404.json in our static directory:

{
    "error" : "404",
    "message" : "page not found"
}

Then, all you have to do is make sure your server redirect to this file in case of a 404. If you’re not familiar on how to set up a server redirect, check out this overview on different ways to go about it.

Adding Teams and Sports

Great job! You just built a simple but fully functioning API using Hugo’s Output formats and custom templates. While we’re on a roll, let’s keep going with some additional features.

Adding Teams

A lot of these steps are similar to creating the endpoint for players. First, we’ll add a teams section to our content directory and add team .md files. We’ll create /content/teams/sly-turtles.md.

---
title: Sly Turtles
mascot: Turtle Todd
---

Sly Turtles were cool before Nemo!

We need a new item object to display a team. All we have to do is create layouts/teams/item.json.json for Hugo to pick it up instantly when rendering a page fromteams.

{
    "title": "{{ .Title }}",
    "mascot": "{{ .Params.mascot }}",
    "permalink": "{{ .Permalink }}"
}

And we have a team: http://localhost:1313/teams/sly-turtles/index.json

{
    "data": {
        "title": "Sly Turtles",
        "mascot": "Turtle Todd",
        "permalink": "http://localhost:1313/teams/sly-turtles/index.json"
    }
}

Improving our response

Now that we have two types of entries, it makes sense to provide some additional information when listing them in our list.json.json. This is the structure of our new response:

  • The type of resource we’re looking at
  • The number of results found
  • An array of results
{{ define "response" }}
{
  {{ with .Section }}
  "section" : "{{ . }}",
  {{ end }}
  "count" : "{{ len .Data.Pages }}",
  "items" : [
  {{ range $i, $e := .Data.Pages }}
  {{ if $i }}, {{ end }}{{ .Render "item" }}
  {{ end }}
  ]
}
{{ end }}

Adding a Taxonomy for Sports

Adding a sports category into the mix is not that complicated. First, we need to open config.toml to tell Hugo two things:

  1. We are declaring a new taxonomy called sports, and
  2. Taxonomies are also compatible with our JSON output format.
[taxonomies]
   sport = "sports"
[outputs]
   page = ["json"] # A player
   section = ["json"] # All players
   home = ["json"] # Everything
   taxonomy = ["json"] # All items from a sport

We can now add sports to our players and teams:

---
title: "Frank J. Robinson"
[...]
sports: ["soccer", "baseball"]

In our list.json.json template, we could then add the possibility of a taxonomy page and enrich the output by checking if the page is of kind section or taxonomy.

{{ with eq .Kind  "section"}}
    "section" : "{{ $.Section }}",
{{ end }}
{{ with eq .Kind  "taxonomy"}}
    "taxonomy" : "{{ $.Data.Singular }}",
    "term" : "{{ $.Data.Term }}",
{{ end }}

We now have a sport index: http://localhost:1313/sports/football/index.json

{
    "data": {
        "taxonomy": "sport",
        "term": "football",
        "count": "3",
        "items": [
            {
                "name": "Jody Garland",
                "contact": "+1 (555) 555 5555",
                "permalink": "http://localhost:1313/players/jody-garland/index.json",
                "year": "freshman"
            },
            {
                "name": "John Artfield",
                "contact": "+1 (555) 555 5555",
                "permalink": "http://localhost:1313/players/john-artfield/index.json",
                "year": "sophomore"
            },
            {
                "title": "Sly Turtles",
                "type": "teams",
                "mascot": "Turtle Todd",
                "permalink": "http://localhost:1313/teams/sly-turtles/index.json"
            }
        ]
    }
}

Creating a generic item.json.json

For the sake of simplicity we added items.json.json to layouts/_default. However, it really only caters to the players endpoint. Let’s move it to layouts/players/item.json.json.

Instead we’ll create a more generic layouts/_default/item.json.json for all other types of entries.

{
  "title": "{{ .Title }}",
  "date": "{{ .Date }}",
  "type": "{{ .Type }}",
  "permalink" : "{{ .Permalink }}",
  "summary" : "{{ .Summary }}"
}

Our API is now Content-type agnostic!

Next Steps

What we built is a stand alone API, but in order to add it as an extra layer to your existing website, all you’ll have to do is drop the templates we created today in your project’s layouts directory and assign the needed output formats to your page kinds, without forgetting to mention the default ones: HTML and for some page kinds, RSS. This way Hugo will output the desired pages in HTML, RSS and JSON:

[outputs]
  page = ["HTML", "json"]
  home = ["HTML", "RSS", "json"]
  section = ["HTML", "RSS", "json"]
  ...

Using Hugo’s Output Formats we were able to tell Hugo to output our pages in JSON. We now have an out of the box RESTful GET API!

Thanks to its templating logic we can now change or add keys to our players’ or teams’ output, add new content type and create a custom output object for them or let them use the default one.

Using Forestry for Content Management

https://res.cloudinary.com/forestry-demo/image/fetch/c_limit,dpr_auto,f_auto,q_80,w_640/https://forestry.io/uploads/2018/04/forestry_admin_demo-1.png

Bootstrapping an API this easily is great, but your content editors might not enjoy working with raw markdown files and dealing with a git repo. Fortunately, we can use the Forestry CMS to instantly add a content management backend for our API!

Get Started With Forestry


From the content side, our JSON API is no different than a conventional Hugo site. This means that managing our content in Forestry will work the same regardless of whether we plan to output JSON or HTML (or both!)

Join us every Friday 📅

Frontend Friday is a weekly series where we write in-depth posts about modern web development.

Next week: We will explore innovative content strategy options within the Forestry CMS

Last week: We compared the usability and features of Hugo and Jekyll to help you decide which static site generator is right for you

Have something to add?

Discuss on Hacker News