29.2 Defining a Custom Output Format
Right, so you’re tired of the same old HTML and want to spit out something a bit more structured, like JSON for an API, or RSS for a feed, or maybe even something like AMP for Google’s fleeting whims. Good news: Hugo’s custom output formats are your new best friend. This is where Hugo stops being just a website generator and starts being a proper content engine. The bad news? The configuration is a bit… idiosyncratic. We’ll get to that.
The core idea is simple: for a given piece of content, you can tell Hugo to render it using multiple templates, each producing a different file type. A single blog-post.md can effortlessly become blog-post/index.html, blog-post/feed.rss, blog-post/data.json, and so on. It’s the whole “create once, publish everywhere” dream, and Hugo actually pulls it off.
The Configuration Tango
First, you have to define your custom formats in your site’s configuration (hugo.yaml, hugo.toml, etc.). This is the part that feels like you’re explaining a complex inside joke to a stranger. You declare the formats and then, crucially, you assign them to specific content types. Let’s say we want to add a JSON output for our blog posts.
# hugo.yaml
outputs:
home: ["HTML", "RSS", "JSON"] # Wait, JSON for the homepage? We'll come back to this.
page: ["HTML", "JSON"] # This means for every regular page (blog post), output HTML and JSON.
outputFormats:
JSON:
name: json
mediaType: application/json
baseName: data # This will create data.json instead of index.json
path: / # Outputs to the same directory as the HTML
See that home entry? That’s Hugo’s first little quirk. The outputs section doesn’t just define what formats are available; it defines which formats are rendered for which Hugo page kind (home, page, section, taxonomy, term). It’s a bit rigid, but it prevents a lot of pointless rendering. If you want a JSON index of your homepage, you’d do it there. For our use case, we’re focused on page.
Crafting the Template Itself
Now, Hugo knows we want a data.json file for every page. But what goes in it? You need a template. The naming convention is critical and follows the pattern: <base-output-format>.<output-format-name>.<extension>.
Since our base format is page (for a single page) and our custom output format is named json, you’ll create a template at layouts/_default/page.json.json. Yes, the double .json looks ridiculous. No, you can’t avoid it. Just accept it and move on. The first one is the content type, the second is the output format.
Let’s make a useful template that spits out the page’s core data.
{{- /* layouts/_default/page.json.json */ -}}
{
"title": {{ .Title | jsonify }},
"permalink": {{ .Permalink | jsonify }},
"content": {{ .Content | plainify | jsonify }},
"date": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}",
"tags": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}{{ $tag | jsonify }}{{ end }} ],
"wordCount": {{ .WordCount }}
}
The jsonify function is your workhorse here. It properly escapes strings and makes sure your output is valid JSON. Notice how we use plainify on the content first to strip the HTML tags—usually what you want for a pure data output. The date formatting uses Go’s bizarre but fixed reference date. Pro tip: build this, then open your-site.com/path/to/post/data.json in your browser and use the JSON formatter extension to validate it. Getting this right on the first try is virtually impossible.
The “Base Name” Quirk and Pretty URLs
I set baseName: data in the config. This means it outputs data.json. If I had left it as the default (which is index), it would output index.json and, because of Hugo’s pretty URLs, it would be accessible at your-site.com/path/to/post/ – which would conflict with the index.html file already living there! The server would have to choose which to serve, leading to unpredictable behavior. Using a distinct base name like data avoids this routing collision entirely by creating a file at your-site.com/path/to/post/data.json. It’s a clean solution to a problem you might not have seen coming.
When Things Get Weird: Section-Specific Formats
Here’s a common pitfall: wanting a custom format only for posts, not for pages. Your layouts/_default/page.json.json template will apply to everything of type page. To make a format only for your blog posts, you need to get clever.
- Create a new content type. In your content directory, add a
blogfolder with aindex.mdthat hastype: blogin its front matter. All your posts go inblog/post-1.md. - In your config, define the output format only for that type:
outputs: blog: ["HTML", "JSON"] # Only content of type 'blog' gets JSON - Create a type-specific template at
layouts/blog/single.json.json.
This is the “Hugo way” to achieve this, but it adds complexity. You have to ask yourself if the sheer coolness of having automatic JSON endpoints is worth the added architectural overhead. For most, the answer is a resounding “yes,” once they get used to it. You’re not just building a site anymore; you’re building an API for your own content.