Hugo is one of the most popular open-source static site generators. With its amazing speed and flexibility, Hugo makes building websites fun again.
Hugo is a static site generator written in Go. It compiles Markdown, Go templates, and data into fully static HTML, CSS, and JavaScript at build time — no runtime server required.
The key distinction for this integration: all external data fetching from APIs like Strapi occurs during the build process. The output is a directory of static files you can deploy to any CDN or static host. Hugo's Go runtime is known for fast builds, which matters when webhook-triggered rebuilds need to propagate content changes quickly.
Hugo v0.159.0 was released on March 23, 2026.
Hugo handles rendering and deployment. Strapi handles content modeling, editing, and API delivery. Together, they give you a content-driven static site with a clean separation of concerns.
Here's what makes this pairing practical:
.title instead of .data.attributes.title, resulting in less chaining and fewer nil pointer errors.This section covers the end-to-end setup: from creating both projects to fetching Strapi content in Hugo templates.
Before starting, confirm you have these installed:
| Tool | Minimum Version | Recommended |
|---|---|---|
| Node.js | 20.x | 24.x |
| Hugo (extended) | 0.146.0 | 0.159.0 |
| npm | Bundled with Node.js | Latest |
You also need a terminal, a text editor, and basic familiarity with REST APIs and Go template syntax.
# Verify installations
node --version # Should output v20.x or v24.x
hugo version # Should output v0.146.0 or laterStart by scaffolding a new Strapi project:
npx create-strapi@latest my-strapi-project
cd my-strapi-project
npm run developThe CLI prompts for a Strapi Cloud login. Skip this for local development. Once the server starts, open http://localhost:1337/admin and create your first admin account.
The Content-Type Builder is only available in development mode, which is the default for locally created projects.
Navigate to Content-Type Builder in the admin panel and create a new Collection Type called Article with these fields:
| Field Name | Type | Notes |
|---|---|---|
title | Text (Short text) | Required |
slug | UID | Attached to title |
body | Rich text (Blocks) | Main content |
featuredImage | Media (Single) | Cover image |
author | Text (Short text) | Author name |
publishedAt | Auto-managed | Handled by draft/publish |
Click Save after adding all fields. Strapi must be manually restarted to register the new schema, which is stored at:
src/api/article/content-types/article/schema.jsonNow add a few articles through the Content Manager. Create entries, fill in the fields, and click Publish to make them available via the API. Draft entries won't appear in API responses by default.
Two options exist for API access. For a Hugo integration, token-based authentication is cleaner than public permissions.
Generate an API token:
Hugo SSG TokenencryptionKey configured in your admin settings, the token displays only onceIf you prefer public access instead, go to Settings → Users & Permissions → Roles → Public, then enable find and findOne for your Article content type.
Verify API access with a quick curl:
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
"https://your-strapi.com/api/articles?populate=*"The response uses Strapi v5's flat format. Fields sit directly on each object in the data array, with no .attributes wrapper:
{
"data": [
{
"documentId": "abc123def456ghi789jkl012",
"title": "My First Article",
"slug": "my-first-article",
"body": [{ "type": "paragraph", "children": [{ "type": "text", "text": "Hello world" }] }],
"author": "Ari",
"featuredImage": { "url": "/uploads/cover_abc123.jpg", "alternativeText": "Article cover" }
}
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 3 } }
}In a separate directory, scaffold a new Hugo project:
hugo new site my-hugo-site
cd my-hugo-siteConfigure the site to store your Strapi connection details. Open hugo.yaml and add:
baseURL: "http://localhost:1313"
languageCode: "en-us"
title: "Hugo + Strapi Site"
params:
strapiBaseUrl: "http://localhost:1337"
strapiToken: "" # Inject via HUGO_PARAMS_STRAPITOKEN env var. Don't hardcode.
caches:
getresource:
maxAge: "10s" # Short for local dev so Strapi changes appear quicklyFor production, inject sensitive values through environment variables using the HUGO_ prefix:
export HUGO_PARAMS_STRAPIBASEURL="https://api.example.com"
export HUGO_PARAMS_STRAPITOKEN="your_production_token"Content adapters can generate pages from remote API data. Create a _content.gotmpl file in your content directory:
content/
└── articles/
├── _content.gotmpl
└── _index.mdAdd a minimal _index.md for the section listing:
---
title: "Articles"
---Now create the content adapter at content/articles/_content.gotmpl:
{{/* Fetch all articles from Strapi v5 */}}
{{ $data := dict }}
{{ $token := site.Params.strapiToken }}
{{ $baseUrl := site.Params.strapiBaseUrl }}
{{ $url := printf "%s/api/articles?populate=author,featuredImage,categories&pagination[pageSize]=100" $baseUrl }}
{{ $opts := dict
"headers" (dict "Authorization" (printf "Bearer %s" $token))
}}
{{ with try (resources.GetRemote $url $opts) }}
{{ with .Err }}
{{ errorf "Strapi API error: %s" . }}
{{ else with .Value }}
{{ $data = . | transform.Unmarshal }}
{{ else }}
{{ errorf "No data returned from Strapi API: %s" $url }}
{{ end }}
{{ end }}
{{/* Generate Hugo pages from Strapi articles */}}
{{ range $data.data }}
{{ $content := dict "mediaType" "text/markdown" "value" .body }}
{{ $dates := dict "date" (time.AsTime .publishedAt) }}
{{ $params := dict
"strapiId" .documentId
"author" .author
"featuredImage" .featuredImage
"strapiBaseUrl" $baseUrl
}}
{{ $page := dict
"content" $content
"dates" $dates
"kind" "page"
"params" $params
"path" .slug
"title" .title
}}
{{ $.AddPage $page }}
{{ end }}This adapter does three things: fetches the JSON response via resources.GetRemote, parses it with transform.Unmarshal, and calls $.AddPage for each article. Hugo treats each generated page like any other content file, with proper dates, params, and section placement.
Important: Older tutorials use getJSON for this purpose. That function was deprecated in Hugo v0.123.0 and later removed in a subsequent release. Use resources.GetRemote for fetching remote resources.
Add a layout for individual articles at layouts/articles/single.html:
{{ define "main" }}
<article>
<header>
<h1>{{ .Title }}</h1>
{{ with .Params.author }}
<p class="author">By {{ . }}</p>
{{ end }}
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "January 2, 2006" }}</time>
</header>
{{/* Featured image with Hugo image processing */}}
{{ with .Params.featuredImage }}
{{ $imgUrl := "" }}
{{ if hasPrefix .url "http" }}
{{ $imgUrl = .url }}
{{ else }}
{{ $imgUrl = printf "%s%s" $.Params.strapiBaseUrl .url }}
{{ end }}
{{ with try (resources.GetRemote $imgUrl) }}
{{ with .Value }}
{{ $resized := .Resize "800x webp" }}
<img
src="{{ $resized.RelPermalink }}"
width="{{ $resized.Width }}"
height="{{ $resized.Height }}"
alt="{{ $.Params.featuredImage.alternativeText }}"
loading="lazy"
>
{{ end }}
{{ end }}
{{ end }}
<div class="content">
{{ .Content }}
</div>
</article>
{{ end }}Let's build a practical developer blog that fetches articles, categories, and images from Strapi v5, processes images through Hugo's pipeline, and deploys automatically when content editors hit publish.
This project uses content adapters for page generation, handles Strapi's pagination for large collections, renders the Blocks rich text format, and wires up a GitHub Actions workflow triggered by Strapi webhooks.
Extend the Article content type from earlier. In the Content-Type Builder, add a Category Collection Type with a name (Text) and slug (UID) field, then add a Relation field on Article pointing to Category (Article belongs to many Categories).
Your final Article model includes:
| Field | Type |
|---|---|
title | Short text |
slug | UID |
body | Rich text (Blocks) |
excerpt | Long text |
featuredImage | Media (Single) |
author | Short text |
categories | Relation (many-to-many with Category) |
After saving and restarting, add a few articles and categories through the admin panel. Publish them to make the content available.
For blogs that might grow beyond Strapi's default page size of 25, the adapter needs to handle pagination:
content/articles/_content.gotmpl:
{{/* Paginated fetch from Strapi v5 */}}
{{ $baseUrl := site.Params.strapiBaseUrl }}
{{ $token := site.Params.strapiToken }}
{{ $page := 1 }}
{{ $pageSize := 100 }}
{{ $hasMore := true }}
{{ $allArticles := slice }}
{{ range seq 1 50 }}
{{ if $hasMore }}
{{ $url := printf "%s/api/articles?populate=*&pagination[page]=%d&pagination[pageSize]=%d" $baseUrl $page $pageSize }}
{{ $opts := dict "headers" (dict "Authorization" (printf "Bearer %s" $token)) }}
{{ with try (resources.GetRemote $url $opts) }}
{{ with .Err }}
{{ errorf "Strapi API error on page %d: %s" $page . }}
{{ else with .Value }}
{{ $response := . | transform.Unmarshal }}
{{ $allArticles = $allArticles | append $response.data }}
{{ $totalPages := $response.meta.pagination.pageCount }}
{{ if ge $page $totalPages }}
{{ $hasMore = false }}
{{ end }}
{{ $page = add $page 1 }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{/* Generate pages from all fetched articles */}}
{{ range $allArticles }}
{{ $article := . }}
{{ $bodyValue := "" }}
{{ if (reflect.IsSlice .body) }}
{{ $bodyValue = . | jsonify }}
{{ else }}
{{ $bodyValue = .body }}
{{ end }}
{{ $content := dict "mediaType" "text/markdown" "value" $bodyValue }}
{{ $dates := dict "date" (time.AsTime .publishedAt) }}
{{ $params := dict
"strapiId" .documentId
"author" .author
"excerpt" .excerpt
"categories" .categories
"featuredImage" .featuredImage
"strapiBaseUrl" $baseUrl
}}
{{ $page := dict
"content" $content
"dates" $dates
"kind" "page"
"params" $params
"path" .slug
"title" .title
}}
{{ $.AddPage $page }}
{{ end }}The range seq 1 50 loop acts as a safety limit. It won't fetch more than 5,000 articles. The $hasMore flag stops iteration once we've retrieved all pages of results.
Strapi v5 offers rich text editors that can output either structured JSON in the Blocks format or plain Markdown, depending on whether you use the Rich Text (Blocks) or Rich Text (Markdown) field. Hugo needs a custom partial to render this. No official Hugo renderer exists for this format, so here's a working implementation.
layouts/partials/strapi-blocks.html:
{{ range . }}
{{ $type := .type }}
{{ if eq $type "paragraph" }}
<p>{{ partial "strapi-inline.html" .children }}</p>
{{ else if eq $type "heading" }}
{{ $tag := printf "h%d" .level }}
<{{ $tag }}>{{ partial "strapi-inline.html" .children }}</{{ $tag }}>
{{ else if eq $type "list" }}
{{ if eq .format "ordered" }}<ol>{{ else }}<ul>{{ end }}
{{ range .children }}
<li>{{ partial "strapi-inline.html" .children }}</li>
{{ end }}
{{ if eq .format "ordered" }}</ol>{{ else }}</ul>{{ end }}
{{ else if eq $type "image" }}
<img src="{{ .image.url }}" alt="{{ .image.alternativeText }}" loading="lazy" />
{{ else if eq $type "code" }}
<pre><code>{{ range .children }}{{ .text }}{{ end }}</code></pre>
{{ end }}
{{ end }}layouts/partials/strapi-inline.html:
{{ range . }}
{{ if .bold }}<strong>{{ end }}
{{ if .italic }}<em>{{ end }}
{{ if .underline }}<u>{{ end }}
{{ if .code }}<code>{{ end }}
{{ if .url }}<a href="{{ .url }}">{{ end }}
{{ .text | safeHTML }}
{{ if .url }}</a>{{ end }}
{{ if .code }}</code>{{ end }}
{{ if .underline }}</u>{{ end }}
{{ if .italic }}</em>{{ end }}
{{ if .bold }}</strong>{{ end }}
{{ end }}Only use safeHTML on content from a trusted source. If your CMS editors can input arbitrary HTML, add sanitization before rendering.
layouts/articles/single.html:
{{ define "main" }}
<article class="blog-post">
<header>
<h1>{{ .Title }}</h1>
<div class="meta">
{{ with .Params.author }}<span class="author">By {{ . }}</span>{{ end }}
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "January 2, 2006" }}</time>
</div>
{{/* Category tags */}}
{{ with .Params.categories }}
<div class="categories">
{{ range . }}
<span class="tag">{{ .name }}</span>
{{ end }}
</div>
{{ end }}
</header>
{{/* Responsive featured image with srcset */}}
{{ with .Params.featuredImage }}
{{ $imgUrl := "" }}
{{ if hasPrefix .url "http" }}
{{ $imgUrl = .url }}
{{ else }}
{{ $imgUrl = printf "%s%s" $.Params.strapiBaseUrl .url }}
{{ end }}
{{ with try (resources.GetRemote $imgUrl) }}
{{ with .Err }}
{{ errorf "Image fetch failed: %s" . }}
{{ else with .Value }}
{{ $sm := .Resize "400x webp" }}
{{ $md := .Resize "800x webp" }}
{{ $lg := .Resize "1200x webp" }}
<img
src="{{ $md.RelPermalink }}"
srcset="{{ $sm.RelPermalink }} 400w,
{{ $md.RelPermalink }} 800w,
{{ $lg.RelPermalink }} 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
loading="lazy"
alt="{{ $.Params.featuredImage.alternativeText }}"
>
{{ end }}
{{ end }}
{{ end }}
{{/* Render Blocks rich text */}}
<div class="content">
{{ with .Params.body }}
{{ partial "strapi-blocks.html" . }}
{{ else }}
{{ .Content }}
{{ end }}
</div>
</article>
{{ end }}If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Hugo documentation.
Hugo is a fast static site generator that compiles Markdown content into static HTML, which is ideal for performance. When integrated with Strapi, Hugo pulls content via Strapi's API and generates pre-rendered static pages. This enhances the speed of your website while allowing content management through Strapi's headless CMS.
Combining Hugo and Strapi offers the best of both worlds: Hugo provides ultra-fast static site generation, while Strapi manages your content efficiently. This integration ensures that your website is both highly performant (thanks to Hugo's static pages) and easily maintainable (with Strapi’s flexible content management system).
To use Hugo and Strapi together, you need:
npx create-strapi-app my-project --quickstartHugo fetches content from Strapi using a custom script or plugin that communicates with the Strapi API. You configure your Strapi API URL in the Hugo project's configuration, and Hugo makes HTTP requests to retrieve content. For instance, you can use a Node.js script to pull content and convert it into markdown for Hugo.
Yes, you can use GraphQL to fetch content from Strapi in Hugo. After enabling the GraphQL plugin in Strapi, you can configure your Hugo project to send GraphQL queries to Strapi for more complex or specific data retrieval, which gives you finer control over the data structure and reduces the amount of data fetched.
Strapi supports multilingual content through its i18n plugin, which allows you to manage translations for each content type. Hugo, when configured properly, can then fetch this multilingual data from Strapi and render it according to the user’s language preferences. Make sure to configure your Strapi i18n settings and set up content in different languages, and then use dynamic routing in Hugo to serve the correct language version.
You can optimize performance by:
To resolve CORS issues, make sure you configure Strapi’s config/middleware.js to allow requests from your Hugo site’s domain. You can add the following settings to enable CORS for development:
module.exports = {
settings: {
cors: {
enabled: true,
origin: ['http://localhost:4000'], // Your Hugo site's URL
},
},
};