Right, so you’ve decided to build a site with Hugo’s speed and Contentful’s editorial-friendly interface. Good choice. This is the “have your cake and eat it too” of the JAMstack world: a dynamic content backend for your editors and a screamingly fast, pre-baked static site for your users. Let’s wire them together without setting anything on fire.

First, the mental model: Hugo is a static site generator. It doesn’t “talk” to Contentful in real-time. Instead, we use Hugo’s built-in power to consume data from external APIs—a feature they call “Data Files,” which is a criminally boring name for something so powerful. We’re going to point Hugo at Contentful’s API, tell it to fetch all our content, and then use that data just like we would with local .md files. The magic happens at build time, not runtime.

The Non-Negotiable Setup

You can’t make an API call without authentication, and Contentful is no exception. You’ll need three things from your Contentful space: your Space ID, an Environment (usually ‘master’), and most importantly, an Access Token. Not the Delivery API Preview token, mind you—unless you want to build drafts into your public site, which is a fantastic way to cause a meeting. Get the Delivery API Access Token.

The professional way to handle these is with environment variables. This keeps your credentials out of your codebase, which is a best practice unless you’re fond of donating your API quota to bots. In your Hugo site’s root, create a .env file:

CONTENTFUL_SPACE_ID=your_space_id_here
CONTENTFUL_ACCESS_TOKEN=your_delivery_api_access_token_here

Then, in your hugo.toml (or config.toml), you’ll read these in and create the API URL Hugo will use. This goes in your [params] section:

[params]
contentful_space_id = "@env.CONTENTFUL_SPACE_ID"
contentful_access_token = "@env.CONTENTFUL_ACCESS_TOKEN"

# Build the API URL using Hugo's sprintf function
apiURL = "https://cdn.contentful.com/spaces/%s/environments/master/entries?access_token=%s&content_type=blogPost"

Why the %s? Because we’re going to use Hugo’s getJSON function to dynamically insert the credentials from our environment variables when it builds, which is far more secure than hardcoding them.

Fetching the Data: The Magic data Directory

This is where the real work happens. Inside your Hugo project’s data directory, you create a file that Hugo will execute to fetch data. The name of the file becomes the key you access the data with. Let’s call it contentful/blogposts.json. The .json extension tells Hugo we’re dealing with a remote data source.

Now, inside data/contentful/blogposts.json, you write this little script:

{{- $spaceID := .Site.Params.contentful_space_id -}}
{{- $accessToken := .Site.Params.contentful_access_token -}}
{{- $apiURL := printf .Site.Params.apiURL $spaceID $accessToken -}}
{{- $content := getJSON $apiURL -}}
{{- jsonify $content -}}

Let’s break this down. The first three lines fetch our params and construct the full, authenticated API URL. The getJSON function is Hugo’s workhorse—it makes a GET request to that URL and unmarshals the JSON response into a data structure we can use. The final line, jsonify $content, re-outputs that data. Hugo then reads this output and makes it available site-wide as .Site.Data.contentful.blogposts. It’s a bit meta, but it works beautifully.

Taming the JSON Beast

Here’s the part Contentful doesn’t highlight in their marketing: the API response is a deeply nested, link-heavy mess. You don’t just get a nice array of blog posts. You get an object containing an items array (your actual entries) and an includes array (your linked assets and entries). This is efficient for API payloads but annoying for a static site builder that’s about to chew through it all anyway.

To access your actual posts, you’ll loop through .Site.Data.contentful.blogposts.items. But what if you want the hero image for a post? That’s not embedded; it’s a sys.id in the post’s fields.heroImage object that points to an asset in the includes.Asset array. You have to resolve it yourself. It’s a pain.

Here’s a best practice: create a partial or a function to resolve these links. Put this in a partial like partials/resolve-contentful-image.html:

{{- /* partials/resolve-contentful-image.html */ -}}
{{- $id := .id -}}
{{- $data := .data -}}
{{- range $data.includes.Asset -}}
    {{- if eq .sys.id $id -}}
        {{- .fields.file.url -}}
    {{- end -}}
{{- end -}}

Then, in your list template:

{{- $data := .Site.Data.contentful.blogposts -}}
{{- range $data.items -}}
    <article>
        <h2>{{ .fields.title }}</h2>
        {{- $imageID := .fields.heroImage.sys.id -}}
        {{- $imageURL := partial "resolve-contentful-image.html" (dict "id" $imageID "data" $data) -}}
        <img src="{{ $imageURL }}" alt="{{ .fields.heroImage.fields.title }}">
    </article>
{{- end -}}

The Build-Time Reality and Caching

Remember, this fetch happens every time you run hugo or hugo server. If you have a lot of content, this can slow down your build. Contentful’s API is fast, but it’s still a network hop. The solution? Hugo caches the response in its resources cache. The first build will be slow, but subsequent builds will be much faster until you trigger a cache clear.

The real gotcha is during development with hugo server. If you make a change in Contentful, your local dev server won’t see it until you restart it. There’s no hot-reload for external data. It’s the trade-off we make for static bliss. So get used to stopping and restarting your server—it builds character.