Right, the i18n/ directory. This is where we stop pretending everyone speaks the same language and start doing the actual, hard work of making your site accessible to a global audience. Don’t call it “translation files”; call them “string dictionaries.” It’s a more accurate term. You’re not just translating words; you’re mapping a key (like homepage.title) to a string of text in a specific language. Hugo uses this directory to pull the correct text based on the site’s current language configuration. It’s brilliantly simple in concept, but the devil, as always, is in the details.

The Basic Structure and File Format

You’ll typically have one file per language, named after the language code, like en.yaml for English, es.yaml for Spanish, or de.yaml for German. You can also use TOML or JSON, but YAML tends to be the most readable for this specific task. The structure inside is a deeply nested dictionary of key-value pairs.

Let’s say you have a greeting on your site. Here’s how your i18n/en.yaml would look:

# i18n/en.yaml
homepage:
  greeting: "Hello, friend!"
  welcome_text: "Welcome to my brilliantly witty site."

And your i18n/es.yaml would map the same keys to Spanish:

# insert i18n/es.yaml
homepage:
  greeting: "¡Hola, amigo!"
  welcome_text: "Bienvenido a mi sitio brillante e ingenioso."

In your template, you don’t hardcode “Hello, friend!”. You use the i18n function and pass the key:

<!-- This is the magic -->
<h1>{{ i18n "homepage.greeting" }}</h1>
<p>{{ i18n "homepage.welcome_text" }}</p>

Hugo automatically looks up the key in the dictionary that matches the current site language. If it can’t find the key in the current language file, it has a fallback procedure (which we’ll get to, because it’s a common source of headaches).

Parameterizing Your Translations

This is where it gets real. You can’t just translate “Hello, friend!” if you want to say “Hello, Anna!” or “Hello, Bob!”. You need to pass a name into the string. This is done with Go templates inside your translation strings. No, seriously.

Here’s the powerful, albeit slightly odd, way you do it:

# i18n/en.yaml
homepage:
  personalized_greeting: "Hello, {{ .name }}!"
# i18n/es.yaml
homepage:
  personalized_greeting: "¡Hola, {{ .name }}!"

And in your template, you pass a dictionary (often called a “dict” in Hugo parlance) containing the name parameter:

{{ $data := dict "name" "Anna" }}
<p>{{ i18n "homepage.personalized_greeting" $data }}</p>

This will render <p>Hello, Anna!</p> for the English site. The ability to use Go templates inside the YAML is both Hugo’s superpower and a potential foot-gun. You can do logic in there, but for everyone’s sanity, keep it to simple variable interpolation.

The Critical Fallback Chain

What happens when a translation string is missing in the Spanish file? Hugo doesn’t just show a blank page (thankfully). It follows a defined fallback chain. It will first look in the exact language (es). If not found, it will look in a language with the same language prefix (e.g., es-ES falls back to es). If still not found, it falls back to the site’s default language, defined in your config.toml (defaultContentLanguage).

This is a lifesaver, but it’s also a pitfall. You might be editing your en.yaml file, see everything working, and forget to add the new key to your fr.yaml file. Your French site will silently render the English text. This is why you need a process. During development, run with hugo server --renderToDisk --i18n-warnings. This will loudly complain in the terminal about missing translation keys, which is exactly what you want. Treat warnings as errors.

The Quirks and Best Practices

First, the questionable choice: Hugo’s i18n support is page-level, not block-level. You set the language for the entire rendered page. Trying to render two languages on a single page is a world of pain and not what it’s designed for. Accept this limitation and move on.

Now, for best practices:

  1. Be Consistent with Keys: Your keys are an API. Use a logical, namespaced structure like section.component.element (e.g., blog.post_nav.previous_button). Once you use a key, never change it.
  2. Don’t Use i18n for Content: This system is for UI strings, button labels, navigation, etc. Your actual blog posts and pages should use Hugo’s built-in content language system (e.g., content/posts/ vs. content/posts.es/).
  3. Context is King: A key like title is useless. Is it the title of a post, a page, the site, or a button? site.title, post.title, and button.submit.title are all better. If a word has multiple meanings (e.g., “like” the verb vs. “like” the preposition), you need separate keys.
  4. Embrace Pluralization with Logic: This is the advanced class. Some languages have complex plural rules. You might need to use a conditional Go template within your string.
# i18n/en.yaml
notification:
  new_messages: 'You have {{ .count }} new message.'
  new_messages_plural: 'You have {{ .count }} new messages.'
<!-- This is a simplified example. Real-world use requires more nuance. -->
{{ $count := len .messages }}
{{ $key := "notification.new_messages" }}
{{ if gt $count 1 }}{{ $key = "notification.new_messages_plural" }}{{ end }}
<p>{{ i18n $key (dict "count" $count) }}</p>

It’s not pretty, but it works. For extremely complex i18n needs, you might be better off with a JS-based solution, but for most sites, Hugo’s system is more than capable if you respect its quirks.