@nxgt/i18n-vue

i18n for a Vue or Nuxt app on @nxgt/i18n's conventions: ICU catalogues checked for missing keys and argument kinds, t() typed and completed in templates, reactive locale switching, and a Nuxt module that resolves the locale on the server.
2,130
Install
npm install @nxgt/i18n-vue
Latest Version:0.4.0
Vue:3
License:MIT
Last Updated:Oct 22, 2026
Links: GitHub  ·  npm
Maintainer: stevegires

@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.
  • t is typed. A generated declaration file makes t('home.title') completed and checked in .vue templates 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. Checking t() in .vue templates takes vue-tsc, which needs TypeScript 6: under 7 tsc checks your .ts files only.
  • vite (>=5), optional — for @nxgt/i18n-vue/vite.
  • nuxt and @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:

  1. the language cookie, when it is a locale of the catalogues;
  2. else the best match for Accept-Language;
  3. 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

  • t throws; 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, ask has(key) before t.
  • 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 to v-html.
  • await setLocale(…) on a server when the locale is in loaders: without it the page renders in the previous locale, since the catalogue is not loaded yet. A locale of catalogues switches 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, a setup binding) hides the global one in its template — which is how const { 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

vue-eyein-translation

Vue 2-3 translation plugin for Vite

4,763 0
@nxgt/mail-i18n

An i18n plugin for a Maizzle project: ICU catalogues checked at build time, t()...

2,828 0
@vintl/nuxt

Nuxt Module for VIntl integration

9,922 2
unplugin-vue-prop

Add import define props type support for Vue3 script-setup and lang is typescrip...

1,105 2
@brc-dd/nuxt-i18n

Minimal module for i18n with locale auto-imports & localized routing

898 12

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