Languages
Multilingual sites with ginko-i18n. Locales, page paths, message files, and the language switcher.
Set the locales
The locales of a site are in locales in ginko.config.ts. Each entry is a locale code. The code is the prefix in the address (/pt-BR/…) and the name of the message file (i18n/pt-BR.json). Any canonical language tag can be a code, for example 'es', 'pt-BR', or 'zh-Hant'. The first locale is the main locale. Its pages have no prefix; the other locales get one:
export default defineSite({
site: 'restaurant',
locales: ['de', 'en'], // /speisekarte (de) and /en/menu (en)
// …
})The code is also the language tag for lang, hreflang, and formats. For a regional tag without a longer address, set languages: with languages: { de: 'de-AT' } the address stays /… and the file stays i18n/de.json, but the pages say lang="de-AT".
You do not configure an i18n module yourself; the starter lists it so its CLI is available. The Ginko module sets up ginko-i18n (@lupinum/ginko-i18n) and gives it the locales from the configuration. ginko-i18n calls the main locale defaultLocale. Do not install @nuxtjs/i18n as well.
Where each option lives
| Option | File | Meaning |
|---|---|---|
locales | ginko.config.ts | The locale codes. The first one is the main locale. |
languages | ginko.config.ts | The language tag per code, for example { de: 'de-AT' }. |
url | ginko.config.ts | The base for canonical and hreflang links. |
timeZone | ginko.config.ts | The time zone for dates and times. |
strategy | nuxt.config.ts, under i18n | 'prefix_except_default' (default): the main locale has no prefix. 'prefix': every locale has one. |
exclude | nuxt.config.ts, under i18n | Paths without a prefix, for example ['/studio']. |
userLocale | nuxt.config.ts, under i18n | Paths whose locale follows the user (see below). |
params | nuxt.config.ts, under i18n | Parameters that every message can use. |
seo | nuxt.config.ts, under i18n | false turns off the head tags of ginko-i18n. |
Ginko gives ginko-i18n locales, defaultLocale, baseUrl, and timeZone itself. If you set one of them under i18n in nuxt.config.ts, the build stops with a message.
ginko-i18n takes the locale from the address. No cookie and no browser header changes the locale of a page with a localized address. One exception is for apps behind a login: the paths you list in userLocale follow the user instead (the cookie ginko_locale, then the Accept-Language header), and /api/** always does. userLocale is an option of ginko-i18n. You set it under i18n in nuxt.config.ts; the Ginko module passes the other i18n options through:
// nuxt.config.ts
export default defineNuxtConfig({
i18n: { userLocale: ['/app/**'] }, // these paths have no language prefix
})ginko-i18n sets lang, dir, canonical, hreflang, and og:locale on every page. Error pages (404, 503) get no canonical and no hreflang, but noindex.
Every page has exactly one URL, and it never ends in / (except / itself). Other spellings (//blog, /blog/, /Blog) redirect there with a 301. Static hosting (nuxt generate) sends no 301: there the browser corrects the address, and the canonical names the one URL.
The texts of the content are stored per locale. If a translation is missing, the bundle shows the text of the main locale. See "Reading content", section "Fallback".
Page paths per locale
A page file sets the path of the main locale. The file app/pages/speisekarte.vue is /speisekarte. For other locales, you set your own path with definePageMeta:
<!-- app/pages/speisekarte.vue -->
<script setup lang="ts">
definePageMeta({ i18n: { paths: { en: '/menu' } } })
</script>
<template><PageMenu /></template>The English page has the path /en/menu. ginko-i18n adds the prefix /en itself. Without this setting, the English path is /en/speisekarte.
Also list every fixed path in pages in ginko.config.ts, with the prefix:
pages: {
home: { de: '/', en: '/en' },
menu: { de: '/speisekarte', en: '/en/menu' },
},Ginko uses pages for the sitemap and for refreshing pages on demand.
useI18n
useI18n() gives you the tools for texts and the language switcher:
| Name | Meaning |
|---|---|
t(key, params) | Returns a text from the message files. |
parts(key, params) | Returns the same text as parts, to render its tags yourself. <I18nT> (below) does this for you. |
has(key) | Tells whether the key exists. |
locale | The code of the current locale, for example 'en'. |
language | The language tag of the current locale, for example 'en-GB'. |
locales | All locales, each with code, language, name, and dir. |
defaultLocale | The code of the main locale. |
localePath(path, code?) | Returns the path of a page in a locale. Without code, the current locale. |
alternates | The current page in every locale, for the language switcher. |
switchLocale(code) | Goes to the current page in another locale. |
format | Formats dates, numbers, amounts, and lists in the current language, for example format.date('2026-10-08'). format.currency takes the amount in major units (12.5). For an f.money field, use formatMoney from @lupinum/ginko. |
locale and language are computed refs: in a script, read locale.value.
localePath takes the path of the main locale: localePath('/speisekarte') returns /speisekarte in the main locale and /en/menu in the English version. Use localePath only for pages of your own site. For the detail pages of entries, use the addresses from the bundle (item.url, or item.urls for every locale).
A language switcher from alternates:
<script setup lang="ts">
const { alternates } = useI18n()
</script>
<template>
<nav aria-label="Language">
<NuxtLink
v-for="a in alternates"
:key="a.code"
:to="a.path"
:hreflang="a.language"
:aria-current="a.current ? 'page' : undefined"
>
{{ a.code.toUpperCase() }}
</NuxtLink>
</nav>
</template>Message files
Fixed texts of the site, such as navigation and labels, live in i18n/<code>.json next to nuxt.config.ts. These are not content from Ginko bricks. You create one file per locale code:
{
"nav": { "menu": "Speisekarte" },
"weekOf": "Woche ab {date}"
}{
"nav": { "menu": "Menu" },
"weekOf": "Week of {date}"
}<script setup lang="ts">
const { t } = useI18n()
</script>
<template>
<h1>{{ t('nav.menu') }}</h1>
<p>{{ t('weekOf', { date: 'October 5' }) }}</p>
</template>The texts use the ICU format. With it you write, for example, plural rules: {count, plural, one {# dish} other {# dishes}}. Keys are names, and nesting separates them: t('nav.menu') reads "menu" inside "nav". So a key must not contain a dot. If a key is missing in a locale, the site shows the text of the main locale. Check the files with npx ginko-i18n check. The starter lists @lupinum/ginko-i18n in package.json; in another site, add it to the dependencies first, so the command finds the local CLI.
For a text with a link or emphasis, write tags in the message, for example "I accept the <link>privacy policy</link>.". <I18nT> fills each tag through a slot of the same name:
<script setup lang="ts">
const { localePath } = useI18n()
</script>
<template>
<I18nT k="legal.consent">
<template #link="{ text }"><NuxtLink :to="localePath('/privacy')">{{ text }}</NuxtLink></template>
</I18nT>
</template>Every feature of ginko-i18n (date and number formats, getI18n in server routes, the rules for links) is in the ginko-i18n README.
Detail pages
An entry can have a different address in each locale. The bundle delivers these addresses in urls. The page file has one path per locale, with the placeholder :slug():
<!-- app/pages/docs/[slug].vue -->
<script setup lang="ts">
definePageMeta({ i18n: { paths: { de: '/doku/:slug()', en: '/docs/:slug()' } } })
const route = useRoute()
const doc = useGinkoEntry('docs', () => route.params.slug)
</script>The path must match the route of the brick. There it is /doku/[slug] for German and /en/docs/[slug] for English.
You do not need to write an i18nAlternates resolver for detail pages. The Ginko module reports the addresses of an entry from the bundle to ginko-i18n. So the language switcher points to the right address in the other locale, and hreflang is correct. If an entry has no page in a locale, that page counts as not translated there.
Use untranslated in the brick to control what happens when a translation is missing:
'hide'(default for bricks with aroute) leaves an entry out of a language while a required translatable field is missing there: no page (404), not in lists, the sitemap or hreflang.'fallback'(default withoutroute) shows the entry with the main-language text. If the brick has aroute, it links to the main-language page until it is translated. Outdated translations hide nothing; the Studio marks them.
A missing slug or image description does not count as a missing translation. The main-language value applies there.
A link to an entry ($<brick>.<name>) in a translated text goes to the main-language page while the entry has no page of its own in that language.
untranslated.