@nxgt/i18n-vue
| Install | |
|---|---|
npm install @nxgt/i18n-vue |
|
| Latest Version: | 0.4.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 22, 2026 |
| Links: | GitHub · npm |
@nxgt/i18n-vue
i18n for a Vue or Nuxt app, on @nxgt/i18n's
conventions: nested catalogues of
ICU
messages, one per locale, and t(key, args). What it adds:
- The catalogues are checked. A key missing in one locale, a key the fallback locale does not have, an argument a translation adds or uses as another kind, a message that is not valid ICU: each throws, naming the locale and the key — at build time with the Vite plugin or the Nuxt module, and again when the app starts.
tis typed. A generated declaration file makest('home.title')completed and checked in.vuetemplates and in TypeScript: an unknown key, a missing argument, or an argument of the wrong kind is a type error.- The locale is reactive, and chosen for you: a stored preference, then
the visitor's languages, then the fallback locale. Under Nuxt it is chosen
on the server (cookie, then
Accept-Language), so the page hydrates in the language it was rendered in.
import { createApp } from 'vue';
import { createI18n, detectLocale } from '@nxgt/i18n-vue';
import en from './locales/en.json';
import fr from './locales/fr.json';
const i18n = createI18n({ catalogues: { en, fr }, locale: detectLocale(['en', 'fr'], 'en') });
createApp(App).use(i18n).mount('#app');
<template>
<h1>{{ t('home.title') }}</h1>
<p>{{ t('home.greeting', { name: user.name }) }}</p>
</template>
0.x. A minor version may still change the surface; the changelog says how.
Install
bun add @nxgt/i18n-vue vue
Peers:
vue(^3.5), required.typescript(6 or 7), required —^6.0.3 || ^7.0.0, as every@nxgt/*package. Bundler resolution ("moduleResolution": "bundler") is what is supported and tested. Checkingt()in.vuetemplates takesvue-tsc, which needs TypeScript 6: under 7tscchecks your.tsfiles only.vite(>=5), optional — for@nxgt/i18n-vue/vite.nuxtand@nuxt/kit(^4), optional — for@nxgt/i18n-vue/nuxt. The main entry never imports them.
Subpaths
| Import | What it is | Runs in |
|---|---|---|
@nxgt/i18n-vue |
createI18n, useI18n, and everything of /core |
browser, server |
@nxgt/i18n-vue/core |
Catalogue checks, createTranslator, pickLocale, parseAcceptLanguage, detectLocale, the types — no vue import |
anywhere |
@nxgt/i18n-vue/node |
loadCatalogues, the shared loader — dir (files, folder layout included) or messages (a module) — for a build that is neither Vite nor Nuxt |
Node, at build |
@nxgt/i18n-vue/vite |
i18nTypes(), the Vite plugin that checks the catalogues and writes the types; re-exports /node and the generator it is made of |
Node, at build |
@nxgt/i18n-vue/nuxt |
The Nuxt module | Node, at build |
@nxgt/i18n-vue/nuxt/runtime |
setupNuxtI18n, what the module's plugin runs, and setupLazyNuxtI18n, its variant with locales loaded on demand |
browser, server |
Catalogues
One JSON file per locale, nested, kebab-case keys — the convention
@nxgt/i18n uses. camelCase is still accepted, for a catalogue written
before this; the two are equivalent, on either side: a key sign-in is also
found by t('auth.signIn'), and the generated types complete and check both
spellings. The fallback locale (the first, by default) is the reference:
every other locale has exactly its keys, and may leave an argument out but
never add one.
// locales/en.json — locales/fr.json has the same keys
{
"home": {
"title": "Welcome",
"greeting": "Hello {name}",
"items": "{count, plural, one {# item} other {# items}}",
"sent-on": "Sent on {at, date, short}"
}
}
An argument's kind comes from how the message uses it: {n, number} and
{n, plural, …} take a number, {at, date} and {at, time} a Date or a
timestamp, anything else a string or a number. See
Catalogues.
Vue
// main.ts
const i18n = createI18n({
catalogues: { en, fr },
fallbackLocale: 'en', // default: the first locale
locale: detectLocale(['en', 'fr'], 'en'), // default: fallbackLocale
});
createApp(App).use(i18n);
Only the locales a visitor uses need to be downloaded: give the others as
loaders, and setLocale fetches and checks a catalogue the first time it is
asked for. Opt-in; catalogues alone works as it always has. The record is
plain import() calls and needs no plugin: Bun, Vite, webpack and esbuild
import JSON as is, and plain Node ESM with
import('./locales/fr.json', { with: { type: 'json' } }).
const i18n = createI18n({
catalogues: { en }, // the fallback locale, and the start locale, are always here
loaders: { fr: () => import('./locales/fr.json'), de: () => import('./locales/de.json') },
});
await i18n.setLocale('fr'); // loads fr.json, checks it against en, then switches
Until the catalogue is loaded, locale and t stay on the previous locale;
when calls overlap, the last one wins — a superseded call resolves without
switching, or still rejects if its load failed. On a server, await setLocale(…)
before rendering. See
Loading a locale on demand.
app.use(i18n) adds t to every template and provides the i18n to
useI18n():
<script setup lang="ts">
import { type Locale, useI18n } from '@nxgt/i18n-vue';
import { computed } from 'vue';
const { t, locale, locales, setLocale } = useI18n();
const title = computed(() => t('home.title'));
const choose = (event: Event) =>
setLocale((event.target as HTMLSelectElement).value as Locale);
</script>
<template>
<h1>{{ title }}</h1>
<select :value="locale" @change="choose">
<option v-for="l in locales" :key="l" :value="l">{{ l }}</option>
</select>
<p>{{ t('home.items', { count: 3 }) }}</p>
</template>
| Member | What it is |
|---|---|
t(key, args?) |
The message in the current locale. Reactive: a template or a computed re-runs when the locale changes |
locale |
The current locale, a read-only Ref |
setLocale(locale) |
Switches every t of the app, and answers a Promise<void>: already resolved for a loaded locale, which switches at once; resolved once the catalogue is loaded and checked for one in loaders, rejected if it cannot be. A locale in neither throws |
locales, fallbackLocale |
The catalogues' locales then the loaders-only ones, and the reference one |
has(key) |
Whether a key computed at run time is a message — t would throw otherwise |
To remember a choice in a browser app, store it where detectLocale reads it:
watch(i18n.locale, (locale) => localStorage.setItem('language', locale));
See Vue.
Choosing the locale
import { detectLocale, parseAcceptLanguage, pickLocale } from '@nxgt/i18n-vue';
pickLocale(['de', 'fr-CA'], ['en', 'fr'], 'en'); // 'fr'
parseAcceptLanguage('fr-CA,fr;q=0.9,en;q=0.8'); // ['fr-CA', 'fr', 'en']
detectLocale(['en', 'fr'], 'en'); // localStorage.language, then navigator.languages, then 'en'
pickLocale takes the wanted locales most wanted first: an exact match wins
(case and _/- do not matter), then a match on the language alone (fr-CA
picks fr, and pt picks pt-BR), then the fallback. See
Choosing the locale.
Types for t — Vite
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { i18nTypes } from '@nxgt/i18n-vue/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue(), i18nTypes({ locales: ['en', 'fr'] })],
});
At every vite dev and vite build — and in vite dev each time a
catalogue changes — it reads locales/<locale>.json, checks the catalogues,
and writes src/generated/i18n.d.ts. A catalogue that cannot be right
fails the build. Git-ignore the file, and keep it in your tsconfig.json's
include (src/** covers it). Then:
<template>
<!-- a type error: 'home.titel' is not a key -->
{{ t('home.titel') }}
<!-- a type error: home.greeting needs { name } -->
{{ t('home.greeting') }}
</template>
| Option | Default | Effect |
|---|---|---|
locales |
— (required) | Every locale, as BCP 47 tags |
fallbackLocale |
the first locale | The reference catalogue, and the one the types are written from |
dir |
'locales' |
The folder of <locale>.json, from Vite's root — and of <locale>/**/*.json, a folder file's path a key prefix. Not with messages |
messages |
— | A module whose default export is the resources object, or a function that returns it — instead of dir. Not with dir |
catalogues |
[] |
Catalogues a package ships, merged under yours key by key — pass the same to layerCatalogues at run time |
out |
'src/generated/i18n.d.ts' |
The file written. A file there that the plugin did not write is never replaced: that throws |
lazy |
false |
Also serve the catalogues as modules — the fallback locale's inline, every other one a chunk of its own — and write their types to <out>.catalogues.d.ts |
With lazy: true, the plugin produces the loaders record for you, from
the same merged catalogues as the types (folder layout and packages
included):
// main.ts
import { createI18n } from '@nxgt/i18n-vue';
import { catalogues, fallbackLocale, loaders } from 'virtual:@nxgt/i18n-vue/catalogues';
createApp(App).use(createI18n({ catalogues, loaders, fallbackLocale }));
vite build splits one chunk per non-fallback locale; in vite dev, a
catalogue change reloads the page with the new modules. It is optional —
loaders written by hand work the same.
Splitting locales/<locale>.json into locales/<locale>/*.json — one file
per feature, say — needs nothing but the files: locales/en/mails.json is
mails.*, locales/en/auth/sign-in.json is auth.sign-in.*. See
Splitting catalogues and
Types.
Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nxgt/i18n-vue/nuxt'],
nxgtI18n: { locales: ['en', 'fr'] },
});
<!-- app/pages/index.vue — t in the template, useI18n auto-imported -->
<script setup lang="ts">
const { locale, setLocale } = useI18n();
</script>
<template>
<h1>{{ t('home.title') }}</h1>
<button type="button" @click="setLocale(locale === 'en' ? 'fr' : 'en')">{{ locale }}</button>
</template>
The module reads locales/<locale>.json at the project's root when Nuxt
starts (nuxt dev, build, prepare) and checks them, adds a plugin that
installs the i18n, writes .nuxt/types/nxgt-i18n-vue.d.ts so t is typed,
and auto-imports useI18n. On each request the locale is:
- the
languagecookie, when it is a locale of the catalogues; - else the best match for
Accept-Language; - else the fallback locale.
It travels in the payload, so the browser hydrates in the same locale, and it
sets <html lang>. setLocale writes the cookie, so the next page is
rendered in the new locale; a locale only guessed from Accept-Language
never sets one. Options: locales, fallbackLocale, dir (folder layout
included) or messages, and catalogues as for Vite, and cookie (default
'language'). A change to a file the module read restarts nuxt dev. See
Nuxt.
lazy: true loads every locale but the fallback one on demand: the plugin
holds the fallback catalogue alone, each other locale is a chunk of its own,
and the server loads the visitor's before it renders, so the page is never in
the fallback locale first. The catalogue it rendered in travels in the
payload, so the browser hydrates without loading it again.
nxgtI18n: { locales: ['en', 'fr', 'de'], lazy: true },
setLocale then answers a promise that resolves once the chunk is loaded;
await it on the server, in a route middleware for instance. See
What lazy generates.
Outside Vue
createTranslator is @nxgt/i18n's translator, strict, and imports no
vue:
import { createTranslator } from '@nxgt/i18n-vue/core';
const t = createTranslator({ en, fr }, () => user.locale);
t('home.greeting', { name: 'Ada' });
t('home.greeting', { name: 'Ada' }, 'fr'); // a language per call
It throws where @nxgt/i18n's answers the key. It does not check the
catalogues — checkCatalogues does, and createI18n calls it. Without
generated types, createTranslator<CatalogueKey<typeof en>>(…) types its keys
from the catalogue itself. See Outside Vue.
Exports
| Export | What it is |
|---|---|
createI18n(options), I18n, I18nOptions |
The i18n of an app: a Vue plugin with a reactive locale |
CatalogueLoader, CatalogueLoaders |
A locale's catalogue loaded on demand — () => import('./locales/fr.json') — and one per locale, for loaders |
useI18n() |
The installed i18n, in a component or where inject works |
createTranslator(catalogues, getLanguage), Translate, LanguageProvider |
t(key, args?, language?) without Vue |
pickLocale, parseAcceptLanguage, detectLocale, WantedLocales, DetectLocaleOptions |
Choosing the locale |
checkCatalogues, layerCatalogues, checkArguments |
The checks createI18n runs, and the merge of a package's catalogues under yours |
createFormatter, Formatter, lookup |
The ICU formatting t uses, with its cache; a message by dotted key, or null |
Catalogue, Catalogues, ArgumentKind, Message, Messages, MessageArgs |
A catalogue as written, catalogues by locale, and a checked message |
I18nMessages, I18nLocales, MessageKey, Locale, MessageArgsOf, KeyOf, ArgsOf, CatalogueKey |
The types the generated file fills, and the ones built on them |
/node: loadCatalogues, loadMessages, readCatalogues, ReadCatalogues, checkCatalogueSource, CatalogueSource, LoadedCatalogues, typesSource, TypesSourceOptions, writeTypes, writeIfChanged |
The shared loader, Node-only, for a build that is neither Vite nor Nuxt |
/vite: everything of /node, plus i18nTypes, I18nTypesOptions, TYPES_FILE |
The Vite plugin |
/nuxt: default, ModuleOptions, checkModuleOptions, pluginSource, PluginSourceOptions, STATE_KEY, TYPES_TEMPLATE, PLUGIN_TEMPLATE |
The Nuxt module |
/nuxt/runtime: setupNuxtI18n, NuxtI18nContext, STATE_KEY, setupLazyNuxtI18n, LazyNuxtI18nContext, CATALOGUE_STATE_KEY |
What the module's plugin runs, and its variant whose locales load on demand (guide) |
Things that bite
tthrows; it never answers the key. An unknown key, an argument left out, one the message does not use, or one of the wrong kind is an error in your code, and the type checker reports it first. For a key computed at run time, askhas(key)beforet.- Every key is kebab-case or camelCase, nested rather than dotted:
{ "home": { "sent-on": … } }or{ "home": { "sentOn": … } }, never"sent_on"or"home.sentOn"as one key. The two conventions are equivalent on a key's segments only — an ICU argument name ({firstName}) is still camelCase-only. - A message is text. Tags are not parsed (
<b>{name}</b>formats to those characters), and Vue's{{ }}escapes the result. Never pass a message with arguments tov-html. await setLocale(…)on a server when the locale is inloaders: without it the page renders in the previous locale, since the catalogue is not loaded yet. A locale ofcataloguesswitches at once either way.- Create one i18n per request on a server. The locale is the i18n's own;
one shared across requests would switch every visitor. The Nuxt module does
this for you. The catalogues are checked once per set of catalogue objects —
{ en, fr }wrapped anew is the same set — so treat them as immutable. - Any component with its own
t(a prop, asetupbinding) hides the global one in its template — which is howconst { t } = useI18n()works. - What the types refuse is measured: 26 cases in TypeScript, 7 in a template and 5 under Nuxt, each failing the typecheck the day it stops being refused. See Types.
Documentation
docs/ — a guide per area, troubleshooting with every message this package throws, and the roadmap.
License
MIT
Related Packages
An i18n plugin for a Maizzle project: ICU catalogues checked at build time, t()...
Add import define props type support for Vue3 script-setup and lang is typescrip...
Version History
| Version | Released | License |
|---|---|---|
| 0.4.0 | MIT | |
| 0.3.0 | MIT | |
| 0.2.0 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |