Comment j'ai divisé par 10 le temps de génération de mon site Hugo
Vous ne le savez peut-être pas, mais le site sur lequel vous êtes actuellement est un site 100% statique. Je gère le contenu côté back avec un CMS en PHP (rédaction, édition, workflow), mais la partie publique n’exécute aucun PHP : pas de base de données, juste des fichiers HTML/CSS/JS et des images. Et tout ça est généré à partir de fichiers Markdown avec Hugo.
Et optimiser tout ça c’est pas de la tarte car si vos templates sont mal pensés, Hugo peut mettre une plombe à générer le site. Entre partiels recalculés pour rien, boucles trop larges et images retraitées à chaque passage, on flingue les perfs sans s’en rendre compte.
Et c’était mon cas au début. Je regardais avec envie les mecs qui sortaient un build en moins d’une seconde tout en restant réaliste car j’ai quand même environ 60 000 pages à générer. Au final, je suis passé de plusieurs heures de build à environ 5 minutes en optimisant les templates, le cache et le pipeline d’assets.
Car oui, Hugo sait générer la plupart des sites en quelques secondes si vos templates sont propres. Depuis la “million pages release”, les builds sont streamés et la mémoire mieux gérée. Alors si votre génération est lente, c’est qu’il y a (souvent) un souci côté templates.
Tout ceci est encore en cours de tests, et ce n’est pas parfait donc il est possible que ça change à l’avenir et vous faites peut-être autrement (dans ce cas ça m’intéresse !!). Bref, voici mon retour d’XP sous forme de conseils pour transformer, vous aussi, votre escargot en fusée 🚀.
Partials - Cachez-moi tout ça (bien)
Hugo sait mettre en cache le rendu d’un partial. Utilisez partialCached
(alias de partials.IncludeCached
) partout où la sortie est identique sur beaucoup de pages.
{{/* baseof.html - Template de base optimisé */}}
{{ partialCached "header.html" . }}
{{ partialCached "nav.html" . }}
{{ partialCached "footer.html" . }}
Le truc malin, c’est d’utiliser des variantes (clés) pour changer le cache selon le contexte. Attention quand même car ces arguments de variante ne sont pas passés au partial. En réalité, ils servent uniquement à nommer l’entrée de cache. Si vous devez passer plus de données au partial, mettez-les dans le context
via un dict
.
{{/* Sidebar: cache par section et par numéro de page */}}
{{ $variant := printf "%s|p%d" .Section (cond .Paginator .Paginator.PageNumber 1) }}
{{ partialCached "sidebar.html" . $variant }}
{{/* Pagination: on passe un contexte enrichi, et on varie par numéro de page */}}
{{ $opts := dict "showCounts" true }}
{{ partialCached "pagination-taxonomy.html" (dict "Page" . "Opts" $opts) .Paginator.PageNumber }}
{{/* Bannières: cache par langue */}}
{{ partialCached "article/patreon-support-banner.html" . .Lang }}
Gardez aussi vos variantes stables et petites (ex. .Lang
, .Section
, .Title
, .Paginator.PageNumber
), sinon, vous allez exploser le cache pour rien.
Réf. : partials.IncludeCached / partialCached.
Hugo Pipes - Minify, fingerprint, bundle (et JS qui dépote)
Pour le basique, pas besoin de Gulp/Webpack car Hugo a tout ce qu’il faut, et c’est très rapide. Voici mon bundle CSS unique :
{{/* head/css.html - Bundle CSS optimisé */}}
{{- $styles := slice
"css/reset.css"
"css/main.css"
"css/components/home-cards.css"
"css/components/patreon-card.css"
"css/components/article-content-images.css"
"css/components/lazy-loading.css"
"css/youtube-placeholder.css"
-}}
{{- $cssResources := slice -}}
{{- range $styles -}}
{{- with resources.Get . -}}
{{- $cssResources = $cssResources | append . -}}
{{- end -}}
{{- end -}}
{{/* Concat + minify + fingerprint */}}
{{- $cssBundle := resources.Concat "css/bundle.css" $cssResources | minify | fingerprint -}}
{{/* Preload + feuille finale avec SRI */}}
<link rel="preload" as="style" href="{{ $cssBundle.RelPermalink }}" integrity="{{ $cssBundle.Data.Integrity }}" crossorigin="anonymous">
<link rel="stylesheet" href="{{ $cssBundle.RelPermalink }}" integrity="{{ $cssBundle.Data.Integrity }}" crossorigin="anonymous">
Je regroupe tout avec resources.Concat
, puis minify
et fingerprint
(SRI + cache-busting). Le preload
déclenche le chargement au plus tôt, et le link
classique prend le relais.
Côté JavaScript, j’utilise js.Build
(esbuild) et je bascule les sourcemaps selon l’environnement :
{{/* assets/js/app.js est mon point d'entrée */}}
{{ $opts := dict
"minify" (not hugo.IsDevelopment)
"targetPath" "js/app.js"
"sourceMap" (cond hugo.IsDevelopment "inline" "") /* inline en dev, rien en prod */
}}
{{ $js := resources.Get "js/app.js" | js.Build $opts | fingerprint }}
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" crossorigin="anonymous" defer></script>
Réfs : Minify, Fingerprint/SRI, js.Build (esbuild), resources.Concat.
Images - Cloudflare Image Resizing + lazy loading intelligent
J’ai également une approche hybride concernant la gestion des images. J’utilise Cloudflare Image Resizing qui génère les variantes côté CDN, et un partial Hugo prépare le srcset
+ LCP propre.
{{/* partials/responsive-image.html */}}
{{/* Paramètres attendus via dict:
- imageURL (string, chemin vers l'image d'origine dans /static)
- imgWidth, imgHeight (int)
- alt (string)
- class (string optionnelle)
- isLCP (bool) */}}
{{ $imageURL := .imageURL }}
{{ $imgWidth := .imgWidth }}
{{ $imgHeight := .imgHeight }}
{{ $alt := .alt | default "" }}
{{ $class := .class | default "" }}
{{ $isLCP := .isLCP | default false }}
{{ $widths := slice 320 640 960 1280 1920 }}
{{ $qualityMap := dict "320" 85 "640" 85 "960" 88 "1280" 90 "1920" 92 }}
{{ $srcset := slice }}
{{ range $w := $widths }}
{{ if or (eq $imgWidth 0) (ge $imgWidth $w) }}
{{ $q := index $qualityMap (printf "%d" $w) }}
{{ $srcset = $srcset | append (printf "/cdn-cgi/image/width=%d,quality=%d,f=avif%s %dw" $w $q $imageURL $w) }}
{{ end }}
{{ end }}
{{ if $isLCP }}
<img
src="/cdn-cgi/image/width=1280,quality=90,f=avif{{ $imageURL }}"
srcset="{{ delimit $srcset ", " }}"
sizes="(max-width: 1280px) 100vw, 1280px"
fetchpriority="high"
width="{{ $imgWidth }}" height="{{ $imgHeight }}"
alt="{{ $alt }}" class="{{ $class }}">
{{ else }}
{{/* LazySizes: placeholder + data-attrs et data-sizes='auto' */}}
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 {{ $imgWidth }} {{ $imgHeight }}'%3E%3C/svg%3E"
data-src="/cdn-cgi/image/width=1280,quality=90,f=avif{{ $imageURL }}"
data-srcset="{{ delimit $srcset ", " }}"
data-sizes="auto"
loading="lazy"
width="{{ $imgWidth }}" height="{{ $imgHeight }}"
alt="{{ $alt }}" class="lazyload {{ $class }}">
{{ end }}
Comme vous pouvez le voir, j’adapte la qualité selon la plateforme (85% pour mobile / 92% pour desktop), le format AVIF par défaut, et pour l’image LCP, je mets fetchpriority="high"
et pas de lazy.
Voici un exemple d’appel :
{{ partial "responsive-image.html" (dict
"imageURL" .Params.featured_image
"imgWidth" 1280
"imgHeight" 720
"alt" .Title
"class" "article-cover"
"isLCP" true
) }}
Cache des ressources - Le secret des rebuilds rapides
Configurez aussi les caches pour éviter de retraiter à chaque build. Le plus important c’est le cache permanent pour les images et autres assets traités.
# hugo.toml
[caches]
[caches.getJSON]
dir = ":cacheDir/:project"
maxAge = "1h"
[caches.getCSV]
dir = ":cacheDir/:project"
maxAge = "1h"
[caches.images]
dir = ":resourceDir/_gen"
maxAge = -1 # NE JAMAIS expirer
[caches.assets]
dir = ":resourceDir/_gen"
maxAge = -1
Et en cas de besoin, pour forcer un refresh : hugo server --ignoreCache
.
Réf. : Configure file caches.
Minifiez tout ce qui bouge (config complète)
J’ai aussi ajouté dans mon hugo.toml
, une config de minification que voici :
# Minification HTML/CSS/JS/JSON/SVG/XML
[minify]
disableCSS = false
disableHTML = false
disableJS = false
disableJSON = false
disableSVG = false
disableXML = false
minifyOutput = true
[minify.tdewolff]
[minify.tdewolff.html]
keepWhitespace = false
[minify.tdewolff.css]
keepCSS2 = true
precision = 0
[minify.tdewolff.js]
keepVarNames = false
version = 2022
precision = 0
Avec hugo --minify
, je gagne ~25% sur le HTML final (variable selon le site).
Réf. : Minify (config).
Organisation des templates - site.RegularPages
est votre ami
En régle générale, c’est mieux d’éviter de boucler sur des pages inutiles (taxonomies, terms…). Utilisez aussi site.RegularPages
plutôt que .Site.Pages
.
{{/* sidebar/recent-posts.html - Articles récents optimisés */}}
{{ $recentPosts := first 6 (where site.RegularPages "Type" "posts") }}
{{ range $recentPosts }}
<li>
<a href="{{ .RelPermalink }}">
{{ $featuredImage := partial "get-image-optimized.html" (dict "page" .) }}
{{ if $featuredImage }}
<img src="{{ $featuredImage.RelPermalink }}" loading="lazy" width="80" height="60" alt="">
{{ else }}
<img src="/img/default-article-image.webp" loading="lazy" width="80" height="60" alt="">
{{ end }}
<h4>{{ truncate 40 .Title }}</h4>
</a>
</li>
{{ end }}
Réfs : site.RegularPages.
Et voici le petit helper image que j’utilise pour éviter les nil
:
{{/* partials/get-image-optimized.html */}}
{{ $page := .page }}
{{ $imageName := .imageName | default $page.Params.featured_image }}
{{ $img := false }}
{{ if $imageName }}
{{ with $page.Resources.GetMatch $imageName }}
{{ $img = . }}
{{ end }}
{{ end }}
{{ return $img }}
Configuration globale - Chaque détail compte
Voici également quelques réglages utiles dans mon hugo.toml
:
# Désactiver ce qu'on n'utilise pas
disableKinds = ["section"]
# Pagination moderne (Hugo ≥ 0.128)
[pagination]
pagerSize = 39 # 39 articles par page
# Traitement d'images
[imaging]
quality = 80
resampleFilter = "Lanczos"
# Optimisations de build
[build]
writeStats = false
noJSConfigInAssets = true
useResourceCacheWhen = "always"
# Mounts: exclure fichiers lourds
[module]
[[module.mounts]]
source = "content"
target = "content"
excludeFiles = ["**/*.zip", "**/*.log", "**/backup/**", "**/archives/**", "**/exports/**"]
[[module.mounts]]
source = "static"
target = "static"
excludeFiles = ["**/*.zip", "**/*.log", "**/backup/**", "**/archives/**"]
Je mets aussi un timeout large (timeout = "600s"
) et j’ignore certains warnings verbeux (ignoreLogs = ['warning-goldmark-raw-html']
). Et pour la pagination, pagerSize
a aussi remplacé les vieux réglages (bon à savoir si vous migrez).
Réf. : PagerSize, Pagination.
Les flags CLI utiles
Perso, j’utilise uniquement les flags suivants quand je lance Hugo :
- Nettoyage :
hugo --gc --cleanDestinationDir
pour virer l’obsolète et garderpublic/
clean. - Dev rapide : gardez
hugo server
tel quel.--disableFastRender
seulement si un glitch l’exige.--renderToMemory
peut accélérer (au prix de la RAM). - Profiler de templates :
hugo --templateMetrics --templateMetricsHints
liste les templates lents et où placer vospartialCached
. C’est comme ça que j’ai vu que ma sidebar coûtait une plombe.
Conditionnez ce que vous chargez
Selon la page, j’adapte aussi mon code. L’image LCP avec fetchpriority="high"
(pour éviter le lazy), le reste en lazy + placeholder SVG qui respecte les dimensions (zéro layout shift). Et pour le JS, defer
partout et pas de scripts inutiles sur les pages qui n’en ont pas besoin.
Le pattern qui tue - partialCached
+ variantes calculées
Mon combo préféré, selon le contexte c’est celui-ci :
{{/* Home: variante par numéro de page */}}
{{ partialCached "pagination-home.html" . (cond .Paginator .Paginator.PageNumber 1) }}
{{/* Grilles d’articles: variante par (page, index de groupe) */}}
{{ partialCached "articles-grid.html"
(dict "articles" $groupArticles "Site" $.Site)
(printf "p%d|g%d" $paginator.PageNumber $groupIndex) }}
{{/* Éléments vraiment statiques: pas de variante */}}
{{ partialCached "footer.html" . }}
Il faut comme ça trouver le bon équilibre. C’est à dire avoir assez de variantes pour éviter les recalculs, mais pas trop, sinon votre cache devient inutile.
Et en bonus - Ma checklist express !
- Remplacer les
partial
chauds parpartialCached
+ variantes propres (au minimum : header, footer, nav, sidebar). - Pipeline assets :
resources.Concat
→minify
→fingerprint
(SRI). - Images : Cloudflare Image Resizing (ou
.Resize
Hugo) + lazy intelligent + placeholder SVG. getJSON
: config de cache (maxAge
) et--ignoreCache
en dev.--templateMetrics
+--templateMetricsHints
pour viser les pires templates.- Pagination : passer à
[pagination].pagerSize
si vous migrez. - Utiliser
site.RegularPages
au lieu de.Site.Pages
dans les boucles. - Module mounts avec
excludeFiles
pour éviter que Hugo scanne backups/archives. - Prod :
--gc
+--cleanDestinationDir
+--minify
. - Cache permanent pour images/assets (
maxAge = -1
).
Voilà. Avec tout ça, je suis passé de plusieurs heures à quelques minutes pour ~60 000 pages. Les clés : partialCached
partout où la sortie ne bouge pas, un bundle CSS/JS propre avec fingerprint/SRI, et site.RegularPages
pour ne pas trimbaler les taxonomies dans les boucles.
N’oubliez pas de lancer hugo --templateMetrics --templateMetricsHints
pour trouver ce qui coûte cher lors de la génération. Vous verrez, dans 90% des cas c’est un partial appelé 10 000 fois non mis en cache, ou une boucle sur .Site.Pages
trop large. Réparez ça, et votre build respira de nouveau.
Et si après tout ça votre build met encore plus de 10 s pour builder moins de 1000 pages, c’est qu’il y a un loup dans vos templates. Réduisez la surface des boucles, chassez les recalculs, et mettez partialCached
partout où c’est pertinent !
Bon courage !