These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
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.
1
2
3
# 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:
1
2
3
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:
1
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 Token encryptionKey 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:
1
2
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"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:
1
2
hugo new site my-hugo-site
cd my-hugo-siteConfigure the site to store your Strapi connection details. Open hugo.yaml and add:
1
2
3
4
5
6
7
8
9
10
11
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 quickly
For production, inject sensitive values through environment variables using the HUGO_ prefix:
1
2
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:
1
2
3
4
content/
└── articles/
├── _content.gotmpl
└── _index.md
Add a minimal _index.md for the section listing:
1
2
3
---
title: "Articles"
---
Now create the content adapter at content/articles/_content.gotmpl:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{{/* 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{{ 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{{/* 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{{ 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
{{ 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
{{ 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.