Right, so you’ve got your content. It’s beautiful. But now you need to get it out into the world in different shapes. You don’t want to just slap an .xml extension on a page and call it a day. You want /articles/my-great-post to be able to serve up its glorious HTML self, a clean JSON representation for some headless CMS nonsense, and a tidy RSS item for the three people (hi, mom!) still using feed readers. The key to this magic trick is telling your static site generator which page kinds should be able to produce which output formats. It’s about association, and Hugo handles this with a concept so simple you’ll wonder why other SSGs make it feel like rocket surgery.

The outputs Declaration in Hugo Config

Think of your hugo.toml (or .yaml or .json) as the mission control center. This is where you define the relationships. Inside this file, you’ll create a section for each page kind (like homepage, page, section) and tell it what formats it’s allowed to output.

Here’s the deal: by default, most page kinds are only configured for HTML. You have to explicitly opt-in to the others. This is Hugo being sensible—it’s not going to assume you want 17 different JSON feeds for every section unless you ask for them.

Let’s look at a realistic config. Say you have a blog and you want your posts (page kind) to output HTML and JSON. You want your home page (home kind) to output HTML and RSS. And you want your blog roll section (section kind) to also output HTML and RSS.

[outputs]
home = ["HTML", "RSS"]
page = ["HTML", "JSON"]
section = ["HTML", "RSS"]

See? Not so bad. The format names are always in all-caps. The order in that array doesn’t matter for production; Hugo’s smart enough to figure it out. You’re just declaring the list of available formats for that kind of page.

Why You Need to Define This for Every Kind

Here’s the common “aha!” moment, followed by a facepalm. Let’s say you set up outputs for your page kind to generate JSON. You run hugo and eagerly check /posts/my-post/index.json. 404. What gives?

You probably put the configuration under the [outputs] section for the page kind, but your actual post is living in a section (e.g., content/posts/). The page kind in Hugo typically refers to regular pages, which are usually your blog posts or articles. The section kind refers to the directory that contains them (like posts/). If you want the section listing itself (e.g., /posts/index.json) to output JSON, you need to declare it for the section kind too.

This tripped me up for a solid hour once. The rule of thumb: a page’s kind is defined by its location in the content tree, not its file extension. A file in content/posts/ is a page; the content/posts/ directory itself is a section.

The Template Naming Convention is Your Law

Okay, you’ve told Hugo what to output. Now you have to tell it how to output it. This is where template naming comes in, and you must get this right. The format is non-negotiable: {kind}.{outputFormat}.html.

Let’s use our config above. For a regular page (a blog post) to output JSON, you need a template named page.json.html. For the RSS feed of your home page, you need home.rss.html. For the RSS feed of a section, you need section.rss.html.

Hugo looks for these templates in your layouts/_default/ directory or, more specifically, in a directory named after the section (e.g., layouts/posts/) if you want to override the defaults for a specific section.

Here’s a bare-minimum, functional page.json.html template to get you started. It outputs a pretty basic JSON representation of a page.

{{- $.Scratch.Set "json" dict -}}
{{- $.Scratch.SetInMap "json" "title" .Title -}}
{{- $.Scratch.SetInMap "json" "url" .Permalink -}}
{{- $.Scratch.SetInMap "json" "content" (.Plain | htmlUnescape) -}}
{{- $.Scratch.SetInMap "json" "date" (.Date.Format "2006-01-02T15:04:05Z07:00") -}}
{{- $.Scratch.SetInMap "json" "lastmod" (.Lastmod.Format "2006-01-02T15:04:05Z07:00") -}}
{{- range .Params.tags }}
  {{- $.Scratch.Add "json" (slice .) -}}
{{- end -}}
{{- ($.Scratch.Get "json") | jsonify -}}

This script creates a dictionary, populates it with data from the current page (.), and then uses the jsonify function to serialize it all into a clean JSON output. The htmlUnescape on .Plain is a nice touch to avoid escaped characters muddying up your JSON.

The Pitfall of Over-Association

Just because you can make every page kind output every format doesn’t mean you should. This isn’t a buffet; it’s a carefully curated menu. Associating outputs willy-nilly has real consequences:

  1. Build Times: Hugo is fast, but it’s not infinitely fast. Telling it to generate JSON, RSS, AMP, and HTML for every single page kind will slow your build down. Be surgical.
  2. Output Bloat: You’ll end up with thousands of files you never use. Your public/ directory will become a terrifying maze of forgotten JSON files and abandoned RSS feeds. This is bad for deployment times and just general peace of mind.
  3. Maintenance Headaches: Every output format you define is another template you have to maintain, update, and potentially debug. Keep it simple.

The best practice is to start with the absolute minimum. HTML for everything. Then, add one output format at a time for the specific page kinds that truly need it. Your homepage and sections need RSS. Maybe only your posts need JSON. Your taxonomies? Probably just HTML. Question every association. Your future self, trying to debug a weird build error at 2 AM, will thank you.