@lupinum/ginko-i18n
Ginko i18n
Nuxt i18n with the URL as the only locale source: translated slugs, SEO head tags, typed keys and an agent CLI, with a message runtime under 2 KB.
Why use Ginko i18n?
- The locale lives in the URL, nowhere else. No cookie, header or browser check changes the page language, so server HTML, hydration and caches always agree. Browser language only drives a suggestion after the page has loaded.
- Translated slugs.
/ueber-unsand/en/aboutare the same page. Dynamic pages (CMS, content) declare their per-locale paths before render. - SEO out of the box:
lang,dir, canonical, hreflang with x-default, andog:localeon every page.@nuxtjs/sitemapgets explicit alternates for static pages. - ICU messages, compiled at build time. Plural, select, ordinal, numbers, dates and tags such as
<link>…</link>. The parser never reaches the browser; the message runtime is under 2 KB gzipped. - Typed keys and parameters. A missing key or parameter fails typecheck.
- One translator everywhere: components, server routes (
getI18n(event)), and plain TypeScript (@lupinum/ginko-i18n/core), for example in Convex functions or emails. - Built for agents.
ginko-i18n check | todo | apply --jsonfinds and fixes missing, stale and broken translations.
Two URL strategies: prefix_except_default (/about, /en/about) and prefix (/de/about,
/en/about). There are no domain or cookie-only strategies, on purpose.
Compared with @nuxtjs/i18n
| Ginko i18n | @nuxtjs/i18n 10.6 | |
|---|---|---|
| Locale source | The URL only. Browser language only drives a suggestion. | The URL, and by default / redirects by cookie and browser language (detectBrowserLanguage). |
| URL strategies | prefix_except_default, prefix |
Also no_prefix, prefix_and_default and domains per locale. |
| Translated slugs | definePageMeta({ i18n: { paths } }) |
The same, or the pages option. |
| SEO head tags | On every page, automatically. | useLocaleHead(), which you call in your layout. |
| Message format | ICU, compiled at build time | vue-i18n syntax (| plurals, @: linked messages) |
| Client JavaScript it adds¹ | 5.4 KB gzipped | 29.3 KB gzipped |
| Translation CLI | ginko-i18n check | todo | apply |
None |
¹ Measured on 12 Oct 2026: Nuxt 4.6.0, two locales, three messages, one page. The JavaScript that /
loads (gzip -9), minus the same app without i18n. bundle.runtimeOnly: true did not change the
@nuxtjs/i18n result.
Choose @nuxtjs/i18n if you need domains per locale, the locale kept out of the URL (no_prefix), or
redirects by browser language. Ginko i18n leaves these out on purpose; DECISIONS.md
gives the reasons.
Requirements
- Nuxt 4.6 or Nuxt 5. Until Nuxt 5 is released, CI tests it on a pinned nightly.
- Node.js 22.18 or later, Node.js 24, or Node.js 26.
- Not together with
@nuxtjs/i18n: setup fails with a message if both are installed.
Installation
pnpm add @lupinum/ginko-i18n
Quick start
1. Add the module to nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@lupinum/ginko-i18n'],
i18n: {
locales: ['de-AT', 'en-GB'], // URL codes become `de` and `en`
defaultLocale: 'de', // a URL code: `/ueber-uns` and `/en/about`
baseUrl: 'https://example.com', // absolute canonical and hreflang URLs
},
})
2. Add one message file per locale in i18n/, next to nuxt.config.ts. The file name is the URL
code, not the language tag: 'de-AT' has the code de (its language subtag, unless you set code), so
its file is de.json:
// i18n/de.json
{
"nav": { "about": "Über uns" },
"cart": { "items": "{count, plural, =0 {Keine Artikel} one {# Artikel} other {# Artikel}}" },
"legal": { "consent": "Ich akzeptiere die <link>Datenschutzerklärung</link>." }
}
// i18n/en.json
{
"nav": { "about": "About us" },
"cart": { "items": "{count, plural, =0 {No items} one {# item} other {# items}}" },
"legal": { "consent": "I accept the <link>privacy policy</link>." }
}
3. Translate in pages and components. useI18n and <I18nT> are auto-imported:
<!-- app/pages/about.vue -->
<script setup lang="ts">
definePageMeta({ i18n: { paths: { de: '/ueber-uns' } } }) // `en` keeps /en/about
const { t, localePath } = useI18n()
</script>
<template>
<main>
<h1>{{ t('nav.about') }}</h1>
<p>{{ t('cart.items', { count: 3 }) }}</p>
<I18nT k="legal.consent">
<template #link="{ text }">
<NuxtLink :to="localePath('/privacy')">{{ text }}</NuxtLink>
</template>
</I18nT>
</main>
</template>
4. Add a language switcher. Each alternate is the current page in another locale:
<!-- app/components/LanguageSwitcher.vue -->
<script setup lang="ts">
const { alternates } = useI18n()
</script>
<template>
<nav>
<NuxtLink
v-for="a in alternates"
:key="a.code"
:to="a.path"
:hreflang="a.language"
:aria-current="a.current ? 'page' : undefined"
>
{{ a.name }}<span v-if="!a.translated"> (home)</span>
</NuxtLink>
</nav>
</template>
5. Render both in app/app.vue:
<template>
<LanguageSwitcher />
<NuxtPage />
</template>
That is the whole setup. Head tags and the switcher's paths follow from it, and so does the sitemap
if you add @nuxtjs/sitemap.
Guides
Links and route names
Link with localePath('/about'). It takes a path written in any locale (/about, /ueber-uns,
/en/about) and returns the same page in the current locale, or in the locale you pass as the second
argument: localePath('/about', 'en').
Do not navigate by route name. The module replaces each page route with one route per locale, named
<name>__<code> (about__de, about__en), so navigateTo({ name: 'about' }) and
<NuxtLink :to="{ name: 'about' }"> no longer resolve. The names are stable, but paths are the
supported way to link.
Dynamic pages (CMS, content)
A page with a slug declares its per-locale paths before render, so the switcher, hreflang and the server HTML agree. The function runs on the server and on client navigation:
// app/pages/blog/[slug].vue
definePageMeta({
i18nAlternates: async (to, { locale, localePath }) => {
const post = await $fetch<{ slugs: Record<string, string> }>('/api/post', { query: { slug: to.params.slug, locale } })
return Object.fromEntries(Object.entries(post.slugs).map(([code, slug]) => [code, localePath(`/blog/${slug}`, code)]))
},
})
Return undefined to fall back to the page's own routes. A locale missing from the result shows as not
translated. Link to content with the paths your content data provides, never with
localePath('/blog/…'): localePath translates app routes, not content slugs.
A Nuxt module that owns dynamic routes for many pages (a CMS) registers one resolver from its plugin
instead. A page's own definePageMeta({ i18nAlternates }) wins; otherwise the first resolver that returns
paths does:
// runtime/plugin.ts of your module
export default defineNuxtPlugin(() => {
addAlternatesResolver(async (to) => {
if (!to.path.startsWith('/docs/')) return undefined // not ours
return { de: '/docs/einstieg', en: '/en/docs/getting-started' }
})
})
Locale options per page
definePageMeta({ i18n: { paths: { de: '/ueber-uns' } } }): translated paths.definePageMeta({ i18n: { locales: ['de'] } }): the page exists only in German.definePageMeta({ i18n: false }): one unlocalized page (for example/studio).
SEO and sitemap
Every page gets <html lang dir>, a canonical link, hreflang links (with x-default for the default
locale) and og:locale. Pages that are not translated into a locale get no hreflang for it. Set
baseUrl so the links are absolute; without it the module uses runtimeConfig.public.siteUrl, and
without both the links are relative (the build warns). Turn the tags off with seo: false. Error
pages (404, 503) are not pages of the site: they get lang, dir and robots: noindex, but no
canonical, hreflang or og:url.
Every page has one URL. The server answers other spellings of it with a 301 to that URL: repeated
slashes (//blog), a trailing slash (/blog/) and a locale code in another case (/EN/about). A
page path in another case (/Blog) redirects to the spelling of the page file or route; a slug keeps
the case the visitor typed, and the page decides whether it exists. URLs therefore never end in /, except the
root. The canonical link and og:url use that one URL.
With @nuxtjs/sitemap installed, the module turns its autoI18n off and gives it explicit alternates,
because @nuxtjs/sitemap pairs locales by stripping the prefix, which is wrong for translated slugs.
This covers static pages only: pages with a dynamic segment (/blog/[slug]) are not in the list. Add
those URLs with their alternates through your own sitemap source, as @nuxtjs/sitemap describes for
dynamic URLs.
Server routes
getI18n(event) translates with the request's locale: the URL locale for pages, and the cookie, then
Accept-Language, for unprefixed paths such as /api/** and for user-locale zones.
// server/api/contact.post.ts
import { defineEventHandler } from 'nuxt/server'
export default defineEventHandler((event) => {
const { t, locale } = getI18n(event)
return { locale, message: t('contact.sent') }
})
Do not cache responses whose language comes from a header or cookie (/api/**, excluded paths, /
with prefix). Vercel ISR and Nitro SWR ignore Vary, so every visitor would get the first
visitor's language. The build warns when a route rule does this.
Server components
Nuxt does not pass the page URL to server components, so they cannot see the locale. Pass it:
<MyCard.server :locale="locale" /> with locale from useI18n(). Dev mode warns when an island
has no locale.
Browser language
The locale never changes on its own. useLocaleSuggestion() returns the visitor's preferred locale
when this page exists in it (null on the server and before mount), so you can offer a link:
<script setup lang="ts">
const suggestion = useLocaleSuggestion()
</script>
<template>
<p v-if="suggestion"><NuxtLink :to="suggestion.path">View in {{ suggestion.name }}</NuxtLink></p>
</template>
With strategy: 'prefix', / has no content: it redirects by cookie, then browser language, with
Vary. Static hosts get a small inline redirect script.
Apps and SaaS
Behind a login, the URL should not carry the language: the user's setting decides. A user-locale
zone is a set of paths whose locale comes from the user (cookie, then Accept-Language, then the
default locale), not from the URL:
export default defineNuxtConfig({
i18n: {
locales: ['de-AT', 'en-GB'],
userLocale: ['/app/**'], // a whole app: ['/**']
},
})
Zone pages have no prefix, no locale clones, no sitemap entry, and no canonical, hreflang or
og:locale (there is no other URL). The head gets lang and dir only. alternates is [].
Marketing pages outside the zone stay URL-localized.
The server reads the ginko_locale cookie, then Accept-Language. It sets the locale once per
request; the client takes it from the payload and never detects it in the browser, so there is no
hydration mismatch. Set the cookie from the user's account setting when they sign in
(ginko_locale=en; Path=/; Max-Age=31536000; SameSite=Lax), so the first render already has the
right language.
Dates in a zone follow the region of the browser's first language when it is the same language and the
locale names no region: en reads 25/10/2026 in an en-GB browser and 10/25/2026 in an en-US one.
Numbers and words stay in the locale's language, and a locale with a region (de-AT) keeps it.
switchLocale(code) changes the language:
<script setup lang="ts">
const { locale, locales, switchLocale } = useI18n()
</script>
<template>
<button v-for="l in locales" :key="l.code" :disabled="l.code === locale" @click="switchLocale(l.code)">
{{ l.name }}
</button>
</template>
In a zone it writes the cookie, loads the messages and re-renders in place (same URL, no reload).
On a URL-localized page it navigates to the same page in code, or to that locale's home when the
page is not translated. An unknown code throws.
- Do not cache zone pages (
isr,swr,cacheroute rules): the language comes from a cookie or header. The build warns, like for/api/**. The module also keeps zones out of the prerenderer, so a static copy never shows the default language to everyone. Zones need a server. excludemeans "not localized": the page renders in the default locale.userLocalemeans "localized by the user": the page renders in the user's locale. Excluded paths (/api/**, Nuxt internals,exclude) are never in a zone, souserLocale: ['/**']leaves them alone.- Entering a zone from a URL page keeps the language of the first page load. Switching on a URL page
never changes the zone's language; only
switchLocaleinside the zone and the cookie do.
Libraries with their own messages
A Nuxt module ships namespaced messages through a hook. The host app can override any key per
locale in its own i18n/ files:
nuxt.hook('ginko-i18n:sources', (sources) => {
sources.push({ namespace: 'cms', dir: resolve('./runtime/i18n') })
})
useI18n('cms') then gives a translator whose keys are relative to that namespace. namespace is
required, so a library never overrides the app's own keys.
Outside Nuxt
The translator also runs without Nuxt: in Convex functions, emails, scripts or another framework.
Compile the catalogs at build time with @lupinum/ginko-i18n/compile, then translate with
@lupinum/ginko-i18n/core, which ships no parser:
import { compileCatalogs } from '@lupinum/ginko-i18n/compile'
import { defineCatalog, type KeysOf } from '@lupinum/ginko-i18n/core'
import de from './i18n/de.json'
import en from './i18n/en.json'
const catalog = defineCatalog<KeysOf<typeof de>>(compileCatalogs({ de, en }), { defaultLocale: 'de' })
catalog.t('en-US', 'mail.invite.subject', { site: 'Gasthaus Grün' }) // a full tag finds `en`
catalog.t(undefined, 'mail.invite.subject', { site: 'Gasthaus Grün' }) // the default locale
compileCatalogs runs the parser, so call it where bundle size does not matter (a build step, a
server), or write its result to a file at build time. The result's shape is not public API: store
your source catalogs, not compiled trees, and compile again after an upgrade.
For one locale, createTranslator is the smaller building block, and the locale-path helpers apply the
module's URL rules anywhere:
import { compileTree } from '@lupinum/ginko-i18n/compile'
import { createTranslator, localizePath, negotiateLocale, resolveConfig } from '@lupinum/ginko-i18n/core'
const { t } = createTranslator({
messages: compileTree({ greeting: 'Hallo {name}' }).tree,
language: 'de-AT',
timeZone: 'Europe/Vienna',
})
t('greeting', { name: 'Romi' }) // "Hallo Romi"
const config = resolveConfig({ locales: ['de-AT', 'en-GB'] })
localizePath('/about', 'en', config) // "/en/about"
negotiateLocale('en-US,en;q=0.9', config) // "en"
Content Security Policy
The first page inlines its locale's messages in a <script> (window.__GINKO_I18N__), so hydration
needs no fetch. With strategy: 'prefix', a static / also has a small inline redirect script. A
strict CSP must allow both (a nonce or a hash). If the inline script is blocked, the client loads the
messages as a script chunk instead.
API
useI18n(namespace?)
Auto-imported in components, pages and plugins.
| Return | Type | Meaning |
|---|---|---|
t(key, params?, options?) |
string |
The message in the current locale. Keys and parameters are typed from the default locale's files. options.timeZone formats this message's dates in another zone. |
parts(key, params?, options?) |
Part[] |
The message as text and tag parts, for custom rendering of <tag>…</tag>. |
has(key) |
boolean |
Whether the key exists in the current locale or the default locale. |
locale |
ComputedRef<string> |
The current URL code ('en'). |
language |
ComputedRef<string> |
The current BCP 47 tag ('en-GB'). |
locales |
LocaleInfo[] |
Every locale: { code, language, name, dir }. |
defaultLocale |
string |
The default locale's code. |
localePath(path, code?) |
string |
The same page in code (default: the current locale). Accepts paths only, not route objects. |
alternates |
ComputedRef<Alternate[]> |
The current page in every locale: a LocaleInfo plus path, current, and translated (false when the page has no version there; path is then that locale's home). |
switchLocale(code) |
Promise<void> |
Go to the current page in code, or change the user's locale inside a zone. |
format |
object | Intl formatting in the current language, see below. |
With a namespace, useI18n('cms') takes keys relative to it (t('save') for cms.save). The result's
type is I18n<NS>, for wrappers that take it as a parameter. New members will not reuse these names.
A missing key renders the key itself (and warns in dev), never an empty string. A key missing in one locale shows the default locale's text.
format formats in the current language and the site's timeZone. Server and client produce the same
text. The examples are for de-AT:
| Method | Example |
|---|---|
format.date(value, style?) |
format.date('2026-10-08') → 8. Oktober 2026. style is 'short', 'medium', 'long' (default), 'full' or Intl.DateTimeFormatOptions. A calendar date (YYYY-MM-DD) never shifts a day. |
format.range(from, to, style?) |
format.range('2026-06-20', '2026-06-21') → 20.–21. Juni 2026 |
format.relative(value, now?) |
vor 5 Minuten, gestern. Call it after mount or pass now: the text depends on the current time. |
format.number(value, options?) |
format.number(1234.5) → 1 234,5 (Intl.NumberFormatOptions) |
format.currency(value, currency) |
format.currency(12.5, 'EUR') → € 12,50. Takes the amount in major units (12.5). For an f.money field, use formatMoney from @lupinum/ginko. |
format.list(items, type?) |
format.list(['Äpfel', 'Birnen', 'Pflaumen']) → Äpfel, Birnen und Pflaumen ('conjunction' or 'disjunction') |
<I18nT>
Renders a message that contains tags. Each tag calls the slot of the same name with { text }, the
tag's content as plain text. A tag without a slot renders its content.
<I18nT k="legal.consent" :params="{ site: 'Gasthaus Grün' }">
<template #link="{ text }"><NuxtLink to="/privacy">{{ text }}</NuxtLink></template>
</I18nT>
k: a full key (library keys include their namespace). A missing key fails typecheck.params: the message parameters. They are not checked against the key;t()checks them.- Parameter values stay text:
<b>inside a value is never rendered as HTML. - A slot gets plain text, so a tag nested inside a slotted tag is flattened.
useLocaleSuggestion()
A ComputedRef<Alternate | null>: the current page in the visitor's browser language, when that is
another locale and the page exists there. null on the server and until the page has mounted. See
Browser language.
addAlternatesResolver(resolver)
For a module's Nuxt plugin that owns dynamic routes. The resolver gets the route and
{ locale, localePath } and returns a path per locale code, or undefined when the route is not its
own. See Dynamic pages.
getI18n(event, namespace?)
Auto-imported in server routes. Returns { locale, language, t, parts, has, format } (type
ServerI18n<NS>) for the request's locale, with the same typed keys, namespace and formatters as
useI18n(). One difference: here locale and language are plain strings ('en'), while
useI18n().locale is a ComputedRef<string>.
definePageMeta
i18n:{ paths?, locales? }orfalse. See Locale options per page.i18nAlternates: a resolver, as foraddAlternatesResolver. See Dynamic pages. It is a key of its own, not part ofi18n: Nuxt readsi18nat build time only while it is plain data, so a function inside it would droppathsandlocales.
@lupinum/ginko-i18n/core
Framework-free and parser-free. See Outside Nuxt.
| Export | Meaning |
|---|---|
createTranslator({ messages, fallback?, language, timeZone?, dateLanguage?, params?, onMissing? }) |
A Translator (t, parts, has) for one locale. Lookup: messages, then fallback, then the key itself. dateLanguage writes the dates in messages in another tag (en-GB). |
defineCatalog<K>(trees, { defaultLocale, params? }) |
A Catalog: t(locale, key, params?, options?), translator(locale), locales, defaultLocale, isLocale(value). An unknown locale gets the default; a full tag (en-US) finds its language. |
KeysOf<typeof catalog> |
The dotted keys of a source catalog type: 'mail.invite.subject' | …. |
createFormatters(language, { timeZone?, dateLanguage? }) |
The format object of useI18n(). dateLanguage writes date and range in another tag (en-GB); numbers and words stay in language. |
resolveConfig({ locales, defaultLocale?, strategy?, exclude?, userLocale? }) |
The resolved locale config for the functions below. |
localizePath(path, code, config) |
Adds the prefix: /about → /en/about. |
stripLocale(path, config) |
Removes it: /en/about → /about. |
localeFromPath(path, config) |
The code a path belongs to, or undefined (/de/x under prefix_except_default). |
negotiateLocale(header, config) |
The best code for an Accept-Language header, or undefined. |
regionalLanguage(language, browser) |
The tag that formats dates in a user-locale zone: the browser's regional variant of language (en + en-GB → en-GB), else language. |
isExcluded(path, config) |
Whether the path never gets a prefix (exclude, /api/**, Nuxt internals). |
inUserZone(path, config) |
Whether the path is in a user-locale zone (userLocale). |
canonicalCase(path, config) |
The path with its prefix in the code's own case (/EN/about → /en/about), or undefined when it is already right. |
cleanPath(path, config) |
The one form of a path: no repeated or trailing slashes, the prefix in the code's own case (//EN/about/ → /en/about), or undefined when it is already right. The server redirects other forms here with a 301. |
alternatesOf(paths, config) |
The hreflang links of a page from its path per code: { hreflang, path }[], with x-default for the default locale. The head tags and the sitemap use it. |
ogLocale(language) |
The og:locale form of a tag: de-AT → de_AT. |
partsToString(parts) |
The plain text of a parts() result. |
Types: Translator, TranslateOptions, Params, Part, MessageTree, Catalog, Formatters,
FormatterOptions, DateStyle, LocaleInfo, LocaleInput, LocaleConfig, Strategy.
@lupinum/ginko-i18n/compile
Build time only: it loads the FormatJS parser.
| Export | Meaning |
|---|---|
compileCatalogs({ de, en }) |
Compiles source catalogs per locale for defineCatalog. |
compileTree(raw) |
Compiles one locale: a CompiledTree { tree, params, numeric }, where params lists each key's parameter names and numeric the ones used as numbers. |
MessageError |
Thrown for invalid ICU. key and source name the message. |
Types: RawTree, CompiledTree. The format inside tree is internal: it can change in any release,
and an old tree is not detected. Compile again after an upgrade.
Types from @lupinum/ginko-i18n
ModuleOptions, PageI18n, MessageSource (for the ginko-i18n:sources hook), I18n (the result of
useI18n), ServerI18n (the result of getI18n), Alternate, AlternatesResolver and TypedTranslator.
Message syntax
Messages are ICU MessageFormat:
| Syntax | Example |
|---|---|
| Argument | Hallo {name} |
Plural (# is the number) |
{count, plural, =0 {No items} one {# item} other {# items}} |
| Ordinal | {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} |
| Select | {role, select, admin {Admin} other {Guest}} |
| Number | {total, number}, {share, number, percent}, {price, number, ::currency/EUR} |
| Date and time | {day, date, long}, {start, time, short} |
| Tag | I accept the <link>privacy policy</link> (rendered with <I18nT>) |
| Literal text | '{' and '<' (an apostrophe quotes) |
Every plural and select needs an other case. Plural and number arguments are typed as number.
Keys are names, and nesting separates them: t('nav.about') reads { "nav": { "about": … } }. So a
key must not contain . and must not be empty. The build, compileTree and the CLI fail on such a key
and show the nested form to use.
Coming from @nuxtjs/i18n
Remove @nuxtjs/i18n first: both modules cannot own the locale, and setup fails if both are installed.
Messages. Ginko i18n reads ICU, not the vue-i18n syntax:
| vue-i18n | ICU |
|---|---|
Hello {name} |
Hello {name} (unchanged) |
no apples | one apple | {count} apples |
{count, plural, =0 {no apples} one {one apple} other {# apples}} |
{n} items with $t(key, n) |
{n, plural, other {# items}} with t(key, { n }) |
@:common.brand (linked message) |
Not supported: write the text, or pass it as a parameter. |
{'@'} (literal) |
@ needs no escape; quote { as '{'. |
@.lower:key (modifier) |
Not supported: format in code. |
Code.
| @nuxtjs/i18n | Ginko i18n |
|---|---|
$t('key') in templates |
const { t } = useI18n(), then t('key') |
useI18n().locale.value = 'en', setLocale('en') |
switchLocale('en') |
localePath('about') (route name), localeRoute, <NuxtLinkLocale> |
localePath('/about') (paths only) with <NuxtLink> |
switchLocalePath('en') |
alternates, or localePath(route.path, 'en') |
<i18n-t keypath="…"> |
<I18nT k="…"> with a slot per tag |
useLocaleHead() |
Nothing to call: head tags are automatic (seo: true). |
$n, $d |
format.number, format.date |
Route names about___en |
about__en; link by path instead (see Links and route names). |
Options.
| @nuxtjs/i18n | Ginko i18n |
|---|---|
locales: [{ code, language, file }] |
locales: ['de-AT', { language: 'en-GB', code: 'en' }]; files are always i18n/<code>.json. |
defaultLocale |
defaultLocale |
strategy: 'prefix_except_default' | 'prefix' |
the same |
strategy: 'no_prefix', 'prefix_and_default', differentDomains |
Not supported: the URL is the only locale source. For apps behind a login, use userLocale. |
detectBrowserLanguage |
No redirect; useLocaleSuggestion() offers a link instead. / under prefix redirects by cookie and browser language. |
customRoutes: 'config', pages: {…} |
definePageMeta({ i18n: { paths } }) in each page |
baseUrl |
baseUrl (falls back to runtimeConfig.public.siteUrl) |
vueI18n: './i18n.config.ts' |
Not needed: formats come from Intl. |
Options
| Option | Default | Meaning |
|---|---|---|
locales |
(required) | BCP 47 tags such as 'de-AT', or { language, code?, name? }. The URL code defaults to the language subtag (de-AT → de); the name to the native name from Intl.DisplayNames. |
defaultLocale |
first locale | The code of the locale with no prefix under prefix_except_default, and the fallback for missing messages: 'de', not 'de-AT'. |
strategy |
'prefix_except_default' |
Or 'prefix': every locale has a prefix and / redirects. |
seo |
true |
Head tags: lang, dir, canonical, hreflang with x-default, og:locale. false turns them off. |
baseUrl |
runtimeConfig.public.siteUrl |
Absolute site URL for canonical and hreflang. Set it in production. |
params |
{} |
Parameters every message can use, for example { tone: 'sie' } for {tone, select, …}. |
exclude |
[] |
Extra unprefixed paths. /api/** and Nuxt internals are always excluded. |
userLocale |
[] |
Paths whose locale comes from the user (cookie, then Accept-Language), not the URL, for apps behind a login: ['/app/**']. See Apps and SaaS. |
timeZone |
'UTC' |
IANA time zone of the site ('Europe/Vienna') for timestamps in format.date and {d, date} / {d, time} in messages. Calendar dates (2026-10-08) always stay on their day. One call can use another zone: t('key', params, { timeZone: site.timeZone }), e.g. in an app that shows times of a site somewhere else. |
A pattern in exclude and userLocale is an exact path ('/studio') or a prefix that ends in /**
('/app/**' matches /app and everything below it; '/**' matches every path). It starts with /.
No other * is allowed: '/app/*' fails setup with a message.
Any other option fails setup with a message: Ginko i18n is not a drop-in for @nuxtjs/i18n options.
A missing translation shows the default locale's text. The build and dev server list the missing
keys per locale, and ginko-i18n check fails CI on them.
CLI
The CLI reads Nuxt's generated ginko-i18n/manifest.json and the original message
files. It needs no browser. It is tested on macOS and Linux.
npx ginko-i18n check --json
npx ginko-i18n todo --locale en --json
npx ginko-i18n apply translations.json --json
Global options are --cwd <app> (defaults to the current directory),
--build-dir <dir> (defaults to <app>/.nuxt), --json, -h/--help and --version.
To check a plain catalog folder with one <code>.json file per locale, with no
Nuxt app, add --dir <folder> --default-locale <code>. Relative paths resolve from
--cwd. --default-locale must name a locale file in the folder, and both options go
together. i18n.lock.json and glossary.json are then read and written in that
folder. Without source directories, check skips the unused-key check and sets
summary.unusedSkipped to true:
npx ginko-i18n todo --dir locales --default-locale de --locale es --json
In this mode todo and apply also accept a locale that has no file yet, such as es
above: apply then creates es.json.
Relative build, input, and HTML paths resolve from the app directory. If the
manifest is absent, the CLI runs the app's local nuxt prepare once. Preparation
writes Nuxt build files; with an existing manifest, check only reads files.
Preparation logs go to stderr. The manifest has a version; a CLI of another
version refuses it (run nuxt prepare again).
With --json, stdout is exactly one JSON object for every command, also for errors. The
shape, error codes and exit codes are the same as those of the ginko CLI:
| Result | Shape |
|---|---|
check |
{ ok, summary, findings }, plus error when ok is false |
todo |
{ ok, locale, language, sourceLocale, sourceLanguage, params, glossary, items } |
apply |
{ ok, written, rejected, warnings }, plus error when ok is false |
| Usage or input error | { ok: false, error: { code, message } } |
ok is true exactly when the exit code is 0. Every ok: false carries
error: { code, message }. code is USAGE for a wrong command line (exit 2) and
INVALID for everything else (exit 1): check errors, rejected apply items, and an
invalid manifest, message file or apply input. --help and --version print plain text
and exit 0.
check reports missing and extra translations, invalid ICU, parameter differences,
incomplete plural categories, stale translations, unknown static keys, and unused
app/layer keys. Parameters declared in the manifest are optional on either side.
Static usages include t('key'), literal backticks, <I18nT k="key">, and simple
const x = useI18n('namespace') followed by x.t('key'). Any dynamic translation
call suppresses unused warnings and sets summary.unusedSkipped to true.
The scanner is a conservative text scan, not a JavaScript parser; comments and
strings containing call-shaped text can count as usages.
Use check --locale <code> to list only one locale's findings, for example while
translating it. ok, summary and the exit code still count every finding, so an
unknown-key error (which has no locale) still fails the run. An unknown code is a
usage error.
Use check --html <built-html-directory> to validate canonical URLs, self and
reciprocal hreflang links, x-default, and HTML language. Error documents and
noindex pages are ignored. URL paths identify local HTML files regardless of origin.
todo --locale <code> lists missing and stale translations. Each item has:
key, andcode:missingorstale, as in the findings ofcheck.file: the fileapplywill write. Keys from a layer go to that layer's folder, which other sites may share; library keys go to the app.source: the default locale's ICU text, andcurrent: the translation, when there is one.params: the source's parameter names.usages: static usages in the code, as{ file, line }.
Use --only missing or --only stale to narrow the list.
The JSON output also includes:
params: global parameters every message can use, fromi18n.paramsinnuxt.config(for exampletone: 'sie'for German Du/Sie). Messages may branch on them withselect; translations do not have to use them.glossary: the app'si18n/glossary.json, ornullif absent. Use it for brand terms, words that must stay untranslated, and tone per locale. Its shape is free.
apply <file> accepts JSON in this form; apply - reads stdin:
{
"locale": "en",
"items": [{ "key": "greeting", "text": "Hello {name}" }]
}
locale must not be the default locale: its files are the source. Each item must
compile and use the source parameters, except global parameters.
Missing plural categories produce a warning in the result's warnings (with sample
numbers, such as many (e.g. 1000000) for Italian) but do not reject a translation.
Valid items are saved even if other items are rejected; written lists them as
{ key, file }, rejected the others as { key, message }. Layer/app keys go to the
source directory that defines the default message. Library keys go to the app's
i18n/<code>.json, including their namespace. Keys follow default-source order;
unrelated keys remain, with two-space indentation and a trailing newline.
The app's i18n/i18n.lock.json records the SHA-256 hash of each translated source.
A changed hash produces a stale warning on subsequent checks. Do not edit source
files concurrently with apply; apply reads and rewrites each affected JSON file.
Each finding in the JSON output has code, severity (error or warning), a message, and
where they apply locale, key, file and line. The code values are:
code |
Severity | Meaning |
|---|---|---|
missing |
error | A locale has no translation for a key of the default locale. |
stale |
warning | The source text changed after the translation was applied. |
extra |
warning | A locale has a key that the default locale does not have. |
plural-missing |
warning | A plural message lacks a category that the locale's language needs. |
params-mismatch |
error | A translation uses other parameters than the source. |
invalid-icu |
error | A message is not valid ICU syntax. |
unknown-key |
error | The code uses a static key that no file defines. |
unused |
warning | No static usage found for a key (skipped when summary.unusedSkipped is true). |
seo-canonical, seo-hreflang-self, seo-hreflang-reciprocal, seo-x-default, seo-lang |
error | Problems that check --html finds in built pages. |
Exit codes: 0 means no errors, 1 means check errors, rejected apply items, or an
invalid manifest, message file or apply input, and 2 means invalid usage. Warnings
alone return 0.
Troubleshooting
Every error starts with [ginko-i18n] and ends with Fix: …. The common ones:
- Unknown option(s) i18n.…: an option from
@nuxtjs/i18n. See Coming from @nuxtjs/i18n. - @nuxtjs/i18n is also installed: remove one of the two modules.
- Two locales use the URL code "…":
de-ATandde-CHboth becomede. Give one an explicitcode. - Message "…" is not valid ICU: the message and its key are in the error. Every
pluralandselectneedsother. - Manifest missing (CLI): add the module to
nuxt.configand runnuxt prepare. - A link by route name does not resolve (
{ name: 'about' }): localized routes are namedabout__de,about__en. Link withlocalePath('/about').
Contributing
Ginko i18n lives in the Ginko monorepo under packages/ginko-i18n. Run pnpm test and pnpm typecheck there before you open a pull request. The design decisions and their evidence are in DECISIONS.md; change one only with new evidence.
Support and security
Ask questions in the Lupinum OSS Discord. Report vulnerabilities privately through GitHub private vulnerability reporting.
License
MIT © Lupinum OG.
Related Packages
Full-featured Convex integration for Nuxt with SSR, real-time subscriptions, and...
Build accessible, route-aware product tours with a Vue-native API and Nuxt-first...
Version History
| Version | Released | License |
|---|---|---|
| 0.8.0 | MIT | |
| 0.7.0 | MIT | |
| 0.6.7 | MIT | |
| 0.6.6 | MIT | |
| 0.6.5 | MIT | |
| 0.6.4 | MIT | |
| 0.6.3 | MIT | |
| 0.6.2 | MIT | |
| 0.6.1 | MIT | |
| 0.6.0 | MIT | |
| 0.5.0 | MIT | |
| 0.4.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |