Markdown Document Composition with Hugo

Using Hugo I frequently end up with sections of content that I need in more than one place, like a list of my focus areas and my experience with technology. Having these copied and pasted over many locations creates the chance of changing them in one while forgetting others.

Hugo’s most straight forward but dirty solution is to create a template for those pages and include multiple markdown files in the template.

Example of a template that combines two markdown content documents:

{{/* layouts/custom/composite-page.html */}}

{{ define "main" }}
<h1>{{ .Title }}</h1>

{{ .Content }}

<section class="experience">
    {{ with .Site.GetPage "/sections/experience.md" }}
    {{ .Content }}
    {{ end }}
</section>
{{ end }}

This is not my preferred solution, because I regard templates as layout templates, or visual templates, not meant to solve content composition but content presentation.

Creating a template each time more than one markdown files should go into a page will eventually move content into templates, spreading content all over the folder structure. Ideally, I want my content in the content folder and nowhere else.

Here’s an example where content ended up in the template, making translation harder:

{{/* layouts/custom/composite-page.html */}}

{{ define "main" }}
<h1>{{ .Title }}</h1>

{{ .Content }}

<h2>My Experience</h2>
<p>My experience is distributed across technologies and industries as follows.</p>

<section class="experience">
    {{ with .Site.GetPage "/sections/experience.md" }}
    {{ .Content }}
    {{ end }}
</section>
{{ end }}

Fortunately, Hugo provides a way to solve this using a Shortcode combined with Headless Bundles.

I am honestly not sure why the one I’m using isn’t built right in.

Store Reusable Stuff as Headless Bundles

Before we write the code, we need a place to store our snippets. We cannot simply drop them into the standard post directory, because Hugo would try to build them into actual, visitable pages (e.g., yoursite.com/posts/my-snippet).

To prevent this, we use a Headless Bundle. This is a directory that contains content resources but does not publish a page itself.

  1. Create a new directory at content/includes.
  2. Inside that directory, create a file named index.md.
  3. Add the following Frontmatter to index.md:
---
title: "Includes"
headless: true
---

The headless: true parameter is the key here. It tells Hugo to treat this folder as a container for resources (images, markdown files, data) that can be accessed programmatically, but to never render a public URL for it.

You can now place your reusable markdown files inside this folder (e.g., content/includes/newsletter-cta.md).

The Shortcode

Now that we have a storage location, we need a mechanism to fetch that content and inject it into our posts. In Hugo, we do this with a custom Shortcode.

Create a new file at layouts/shortcodes/include.html and paste the following logic:

{{/* Get the "headless" bundle page reference */}}
{{ $headless := .Site.GetPage "/includes" }}

{{/* Find the specific file passed as a parameter */}}
{{ $snippet := $headless.Resources.GetMatch (.Get 0) }}

{{/* If the file exists, render its content */}}
{{ with $snippet }}
    {{ .Content }}
{{ end }}

Explained

Using the Shortcode in Markdown Content

Content from markdown documents can now be inserted into other markdown documents using the shortcode:

# My Experience

My experience is distributed across technologies and industries as follows.

{{< include "experience.md" >}}

# My Focus Areas

{{< include "focus-areas.md" >}}

# More Blah Blah

{{< include "bla.md" >}}

This approach plays nicely with multi-language Hugo sites, as you can create the /includes folder within each language directory.

This syntax automatically picks the correct translation from the file tree.

{{< include "focus-areas.md" >}}
👋 Hello, I am Denis,

I am a freelance software architect, full-stack developer, and co-founder of DenktMit, a network of independent IT experts.

Got questions or a project in mind? Get in touch via LinkedIn or email.

back to blog list