Creating Unlisted Content in Hugo

Published August 11, 2020

Reading time: 7 minutes.


You may want pages that are available via a direct link, but not displayed in list pages, related content, or feeds. By default, Hugo collects all content into collections that themes use to build list pages.

You can do this by adding a new piece of front matter to your pages, and then altering your themes to filter those pages out. In this tutorial you’ll see exactly what queries to change, and how to change them, so you can publish your own unlisted content.

Filtering Unlisted Pages From Queries

Creating unlisted pages involves two steps: adding the front matter field, and modifying any iterations in your theme to ignore them.

In this example, we’ll use unlisted: true in the front matter for a page. Create a new page called unlisted.md using the hugo command:

$ hugo new unlisted.md

Open contents/unlisted.md in your editor and change the front matter to include a new unlisted: true field. And make sure that draft is false so Hugo generates the page:

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: false
unlisted:true

You’ll add this new field to any pages you wish to hide.

Now locate your template that lists pages in your site. The default one is usually located in your theme folder in layouts/_default/list.html. You’ll find logic that looks like this:

 <ul>
    {{ range .Pages }}
      <li>
        <a href="{{ .RelPermalink }}">{{ .Title }}</a>
      </li>
    {{ end }}
  </ul>

This logic iterates over all the Pages in the site. It might say RegularPages or .Site.RegularPages, depending on who created the theme.

To filter out the unlisted pages, change the range query to use a where function to filter out any pages where the unlisted field is set to true:

 <ul>
    {{ range (where .Pages ".Params.unlisted" "!=" "true") }}
      <li>
        <a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
    {{ end }}
  </ul>

If you have more specific templates, you’ll need to change the scopes there as well. For example, a template that iterates through posts might have a range query that looks for a type of posts, rather than all pages on the site. Here’s an example of that:

{{ range (where .Site.RegularPages "Type" "in" "posts").ByDate.Reverse }}

Hugo’s query language doesn’t support AND operations, so you’ll have to nest the where functions to ensure that both conditions apply:

{{ range (where (where .Site.RegularPages "Type" "posts") ".Params.unlisted" "!=" "true").ByDate.Reverse }}

Some themes display the most recent blog posts on the site’s home page. In your site’s index.html template, you might have a snippet that pulls in the most recent posts, like this:

{{ range first 3 (where .Site.RegularPages "Type" "in" "posts").ByDate.Reverse }}

This is another place you’ll want to add the nested where function to filter out the unlisted content:

{{ range first 3 (where (where .Site.RegularPages "Type" "posts") ".Params.unlisted" "!=" "true").ByDate.Reverse }}

You’ll want to carry these changes through to any other specific list templates in your theme, including ones for tag lists and category lists.

Once you’ve adjusted your general range queries, you’ll want to address pagination and related content.

If you’re paginating your content, you’ll use the same approach you’ve used before. A paginator for posts in your current theme may look like this:

{{ $paginator := .Paginate (where .Pages "Type" "posts") }}

Add the unlisted filter with a nested where:

{{ $paginator := .Paginate (where (where .Pages "Type" "posts") ".Params.unlisted" "!=" "true") }}

Finally, Hugo has a built-in feature for displaying related content on your pages. You may have a partial that looks like this:

{{ $related := .Site.RegularPages.Related . | first 5 }}
{{ with $related }}
<h3>You might also be interested in...</h3>
  <ul>
    {{ range .ByDate.Reverse }}
      <li><a href="{{ .RelPermalink }}">{{ .Title }}</a> (Published {{ .PublishDate.Format "January 2, 2006" }})</li>
     {{ end }}
  </ul>
{{ end }}

To ensure that the unlisted content is filtered out, wrap the .Site.RegularPages.Related function with a where clause:

{{ $related := (where (.Site.RegularPages.Related . ) ".Params.unlisted" "!=" "true") | first 5 }}

Now the related content excludes the unlisted content. But you might be exposing that content in your feeds.

Fixing Your RSS Feed

You’ve excluded your unlisted content from your pages, but don’t forget about your site’s RSS feed. Hugo has a default RSS template that you can override, but if you don’t, Hugo uses this built-in default. That means you may accidentally expose your unlisted content through your feeds.

To override this file, create the file layouts/_default/rss.xml in your site or in your theme and modify it to exclude the unlisted pages just like you’ve done with other queries.

The default template creates a $pages variable and populates it based on various conditions, such as whether this is the home page, or if the content should be limited to a certain number of records:

{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}

The quickest way to hide the unlisted pages is to apply the where clause after the limit:

{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- $pages = (where $pages ".Params.unlisted" "!=" "true") -}}

Be sure to use the {{- -}} notation here to suppress space before and after the line.

The entire RSS feed template with the changes looks like this:

{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- $pages = (where $pages ".Params.unlisted" "!=" "true") -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>{{ if eq  .Title  .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}</title>
    <link>{{ .Permalink }}</link>
    <description>Recent content {{ if ne  .Title  .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }}</description>
    <generator>Hugo -- gohugo.io</generator>{{ with .Site.LanguageCode }}
    <language>{{.}}</language>{{end}}{{ with .Site.Author.email }}
    <managingEditor>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</managingEditor>{{end}}{{ with .Site.Author.email }}
    <webMaster>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</webMaster>{{end}}{{ with .Site.Copyright }}
    <copyright>{{.}}</copyright>{{end}}{{ if not .Date.IsZero }}
    <lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
    {{ with .OutputFormats.Get "RSS" }}
  {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
    {{ end }}
    {{ range $pages }}
    <item>
      <title>{{ .Title }}</title>
      <link>{{ .Permalink }}</link>
      <pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
      {{ with .Site.Author.email }}<author>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</author>{{end}}
      <guid>{{ .Permalink }}</guid>
      <description>{{ .Summary | html }}</description>
    </item>
    {{ end }}
  </channel>
</rss>

If you have other alternative formats, like a JSON feed, be sure to exclude the content in a similar way. And remember to add the unlisted: true entry to your front matter.

Conclusion

You can now add a new front matter field to your content and have it excluded from Hugo’s generated content lists, but still have the pages published so you can provide people with direct links to the content.

Remember that in order for this to work, you’ll need to change any place that iterates over your content, including lists for alternative content types, such as your site’s automatically-generated sitemap or XML feed. Check and double check your site to ensure your unlisted content is truly unlisted before you publish.


Thanks for reading

I don't have comments enabled on this site, but I'd love to talk with you about this article on BlueSky, Mastodon, Twitter, or LinkedIn. Follow me there and say hi.


Liked this? I have a newsletter.