Right, so you want to build a JSON API with Hugo. Good choice. It’s a shockingly capable static API engine, and it saves you from having to maintain a separate database and server just to serve some structured data. We’re going to move beyond the basic json output format and build something you’d actually be happy to have a frontend app consume.

First, let’s address the elephant in the room: Hugo is a static site generator. It builds files. An API typically implies dynamic requests. The key here is that we’re building a static API. All the possible data responses are pre-rendered as JSON files at build time. This is brilliant for read-only data (blog posts, product catalogs, documentation) because it’s insanely fast, secure, and cheap to host. It’s a terrible idea for anything that needs real-time, user-specific data. Don’t try to build a stock trading platform with this.

The Foundation: A Dedicated Section for Your API

You don’t want your API endpoints mixed in with your HTML pages. The cleanest way to handle this is to create a new dedicated section. In your content directory, create a folder, let’s say content/api/.

Inside that, create a _index.md file. This file is the home for the section itself and, crucially, is where we’ll set the type of output for every page in this section. We’re going to tell Hugo that everything in this /api/ section should only be rendered as JSON.

# content/api/_index.md
---
title: "My Brilliant API"
outputs:
  - json
---

This outputs: [json] front matter is the magic spell. It tells Hugo to ignore the default HTML templates and only use the JSON ones for this entire branch of your content tree.

Crafting the JSON Template

Now, we need to tell Hugo what JSON to output. This happens in your layouts/ directory. Since we specified outputs: [json] for the section, Hugo will look for a template called layouts/api/single.json.json. Yeah, the double .json looks weird, but it makes sense: the first is the template name, the second is the output format extension.

Let’s create a template that spits out a clean, useful JSON representation of a post.

{{- /* layouts/api/single.json.json */ -}}
{
  "id": "{{ .File.UniqueID }}",
  "title": "{{ .Title }}",
  "date": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}",
  "permalink": "{{ .Permalink }}",
  "summary": "{{ .Summary | plainify }}",
  "content": {{ .Content | jsonify }},
  "tags": [{{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}"{{ end }}]
}

Let’s break down the important bits:

  • We use {{- and -}} to trim whitespace. This is critical for generating clean, invalid-whitespace JSON, not a pretty-printed mess for a human.
  • .File.UniqueID is a great, stable internal identifier for the content file.
  • For the date, we format it as RFC3339, which is the standard for JSON APIs. Remember, Hugo’s magic date string is "2006-01-02"—don’t ask, just memorize it.
  • .Summary | plainify strips HTML tags from the auto-generated summary.
  • jsonify is your best friend. This function escapes all special JSON characters within a string. You must use this on any free-text field (like .Content) or you will generate invalid JSON the first time someone uses a quote " in their article. This is the number one pitfall.
  • For the tags array, we use a classic range loop with an index check to correctly place the commas without a trailing one.

Generating a List or Index Endpoint

A single endpoint is nice, but a real API needs a list view. This is where section templates come in. Create layouts/api/list.json.json.

{{- /* layouts/api/list.json.json */ -}}
{
  "metadata": {
    "count": {{ len .Pages }},
    "generated_on": "{{ now.Format "2006-01-02T15:04:05Z07:00" }}"
  },
  "items": [
    {{- range $index, $page := .Pages -}}
      {{ if $index }}, {{ end }}
      {
        "id": "{{ .File.UniqueID }}",
        "title": "{{ .Title }}",
        "permalink": "{{ .Permalink }}",
        "date": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}"
      }
    {{- end -}}
  ]
}

This gives you a paginated-by-default list of all your API items. Notice we’re keeping the individual items in the list lightweight—just enough data for a listing view. We’re not dumping the full content here, which is a best practice. If a client wants the full post, they can follow the permalink to the single.json endpoint we built earlier.

Taking Control with Custom Output Formats

The above method works, but it locks the entire /api/ section to JSON. What if you want an HTML preview of the same data? Or you want multiple JSON formats (e.g., a full version and a minimal version)? This is where Custom Output Formats shine.

Define them in your hugo.toml:

# hugo.toml

[outputs]
home = ["HTML", "RSS", "JSON"] # Keep your defaults

# Define a new output format for a full API response
[outputFormats.FullJSON]
mediaType = "application/json"
baseName = "index"
path = "api"

# And another for a minimal one
[outputFormats.MinimalJSON]
mediaType = "application/json"
baseName = "index"
path = "api-min"

Now, in your content/api/_index.md, you can specify multiple outputs:

# content/api/_index.md
---
title: "My API"
outputs:
  - FullJSON
  - MinimalJSON
  - HTML  # So you can have a pretty browser-viewable index too!
---

You’d then create corresponding templates: layouts/api/single.fulljson.json and layouts/api/single.minimaljson.json. This is the most powerful and flexible way to build your API, giving you total control over the structure and volume of data you serve for different use cases.