Hugo Multilingual Sites with Subfolder URLs (/en/, /es/)
Configure languages, translation links, and hreflang without subdomain chaos. Battle-tested for 3 locales on Vercel.
Tested on: Hugo 0.139.4, 3 languages (en, es, de), Vercel + Cloudflare
URL shape: example.com/en/blog/post-slug/ — no www.es.example.com
Why subfolders beat subdomains
- One TLS cert, one Search Console property (with path rules)
- Shared assets cache
- hreflang alternates are simpler
hugo.toml language block
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = true
[languages.en]
languageCode = "en-us"
languageName = "English"
weight = 1
title = "My Blog"
[languages.en.params]
description = "English tutorials"
[languages.es]
languageCode = "es-es"
languageName = "Español"
weight = 2
title = "Mi Blog"
[languages.es.params]
description = "Tutoriales en español"
[languages.de]
languageCode = "de-de"
languageName = "Deutsch"
weight = 3
defaultContentLanguageInSubdir = true is the key — without it English lives at / and others at /es/, breaking symmetry.
Content file layout
content/
├── en/blog/my-post.md
├── es/blog/my-post.md
└── de/blog/my-post.md
Each file:
---
title: "My Post"
translationKey: "my-post-2026"
date: 2026-05-01
---
Same translationKey ties translations together for .Translations and hreflang partials.
hreflang partial
layouts/partials/hreflang.html:
{{- if .IsTranslated -}}
{{- range .AllTranslations -}}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}" />
{{- end -}}
<link rel="alternate" hreflang="x-default" href="{{ .Site.Home.Permalink }}" />
{{- end -}}
Add to layouts/partials/head.html.
Language switcher
<nav aria-label="Language">
{{- range site.Languages -}}
{{- $url := "/" -}}
{{- if $.IsTranslated -}}
{{- range $.Translations -}}
{{- if eq .Language.Lang .Lang }}{{ $url = .RelPermalink }}{{ end -}}
{{- end -}}
{{- else if eq .Lang $.Lang -}}
{{- $url = $.RelPermalink -}}
{{- end -}}
<a href="{{ $url }}" hreflang="{{ .Lang }}"
{{- if eq .Lang $.Lang }} aria-current="page"{{ end -}}
>{{ .LanguageName }}</a>
{{- end -}}
</nav>
Errors I hit
WARN found duplicate translation with key "my-post"
Two Spanish files shared the same translationKey but different slugs. One slug per translationKey per language.
404 on /es/ after deploy
Vercel vercel.json rewrite sent everything to index.html (SPA mode). Static Hugo output must be served as files:
{
"buildCommand": "hugo --minify",
"outputDirectory": "public"
}
Remove SPA rewrites.
menus per language
hugo.toml:
[[languages.en.menu.main]]
name = "Blog"
url = "/blog/"
weight = 1
[[languages.es.menu.main]]
name = "Blog"
url = "/blog/"
weight = 1
URLs are relative to language root automatically.
Verify
hugo --minify
ls public/en/blog/ public/es/blog/ public/de/blog/
curl -s http://localhost:1313/en/blog/my-post/ | grep hreflang
Result
3 locales, 1 repo, 1 deploy pipeline. Organic ES traffic +18% after hreflang fix.