# Vue i18n Best Practices: A vue-i18n and Nuxt i18n Guide

> Production-tested vue-i18n and Nuxt i18n practices: Composition API setup, vue-i18n's plural syntax, lazy-loaded locales, number and date formats, typed keys, SEO, and testing.
- **Author**: Mohit Sapat
- **Published**: 2026-10-08
- **URL**: https://www.babelize.co/blogs/vue-i18n-best-practices

---

<p class="lead">
    Vue makes it easy to add a second language and surprisingly easy to add it badly. A few <code>$t()</code> calls work fine in a demo; the problems show up later, when there are a dozen locales, a few thousand keys, Polish plurals, and a Nuxt app that has to rank in every market. This guide collects the vue-i18n and Nuxt i18n practices that hold up in production, with code written for the current releases: vue-i18n v11 and @nuxtjs/i18n v10.
</p>

<h2>The Short Answer</h2>

<p>
    <strong>Use the Composition API</strong> (<code>createI18n({ legacy: false })</code> plus <code>useI18n()</code>), keep messages in JSON files per locale, <strong>lazy-load every locale except the default</strong>, format numbers and dates with <code>n()</code> and <code>d()</code> instead of by hand, and type your message keys from the source locale. In Nuxt, let <code>@nuxtjs/i18n</code> own routing, lazy loading, and SEO tags rather than rebuilding them yourself.
</p>

<h2>Which Versions This Guide Covers</h2>

<table>
    <thead>
        <tr>
            <th>Package</th>
            <th>Current major</th>
            <th>What matters</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>vue-i18n</code></td>
            <td>v11</td>
            <td>Legacy API deprecated (removal planned for v12), <code>tc()</code>/<code>$tc()</code> removed, <code>v-t</code> directive deprecated. v9 and v10 have reached end of life.</td>
        </tr>
        <tr>
            <td><code>@nuxtjs/i18n</code></td>
            <td>v10</td>
            <td>Ships vue-i18n v11, lazy-loads all locale files, resolves files from the <code>i18n/</code> directory.</td>
        </tr>
        <tr>
            <td><code>@intlify/unplugin-vue-i18n</code></td>
            <td>v11</td>
            <td>Precompiles messages and enables SFC <code>&lt;i18n&gt;</code> blocks in Vite and webpack.</td>
        </tr>
    </tbody>
</table>

<p>
    If you are upgrading from vue-i18n v9 or v10, the Composition API code below works on all three versions; the main breaking changes are the removed <code>tc()</code> and the deprecated Legacy API.
</p>

<h2>1. Set Up vue-i18n with the Composition API</h2>

<p>Install the library and, for Vite projects, the bundler plugin:</p>

<pre><code>npm install vue-i18n
npm install -D @intlify/unplugin-vue-i18n</code></pre>

<p>Create the i18n instance in its own module so components, router guards, and stores can share it:</p>

<pre><code>// src/i18n.ts
import { createI18n } from "vue-i18n";
import en from "./locales/en.json";

export const SUPPORTED_LOCALES = ["en", "fr", "de", "pl"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];

export const i18n = createI18n({
    legacy: false, // Composition API mode; the default is still true in v11
    locale: "en",
    fallbackLocale: "en",
    messages: { en },
    missingWarn: import.meta.env.DEV,
    fallbackWarn: import.meta.env.DEV,
});</code></pre>

<pre><code>// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import { i18n } from "./i18n";

createApp(App).use(i18n).mount("#app");</code></pre>

<p>In components, call <code>useI18n()</code> and use the returned functions in the template:</p>

<pre><code>&lt;script setup lang="ts"&gt;
import { useI18n } from "vue-i18n";

const { t, locale } = useI18n();
const props = defineProps&lt;{ name: string }&gt;();
&lt;/script&gt;

&lt;template&gt;
    &lt;h1&gt;{{ t("home.greeting", { name: props.name }) }}&lt;/h1&gt;
    &lt;p&gt;{{ t("home.currentLocale", { locale }) }}&lt;/p&gt;
&lt;/template&gt;</code></pre>

<p>
    <strong>Why <code>legacy: false</code> matters:</strong> <code>createI18n</code> still defaults to the Legacy API in v11, and <code>@intlify/unplugin-vue-i18n</code> tree-shakes the Legacy API out of production builds by default (its <code>compositionOnly</code> option is <code>true</code>). Forgetting the flag gives you an app that works in development and breaks after a build.
</p>

<p>
    Outside components, in router guards, Pinia stores, or API clients, use the global composer directly: <code>i18n.global.t("errors.network")</code> and <code>i18n.global.locale.value</code>. Calling <code>useI18n()</code> outside a component's <code>setup</code> throws.
</p>

<h2>2. Learn vue-i18n's Message Syntax (It Is Not ICU)</h2>

<p>
    vue-i18n has its own message format. It looks a little like ICU MessageFormat, but plurals and special characters work differently, and ICU strings copied from another project will not render as expected.
</p>

<pre><code>{
    "home": {
        "greeting": "Hello, {name}!",
        "position": "You are number {0} in line",
        "brand": "Welcome to @:common.appName",
        "support": "Email support{'@'}example.com"
    },
    "common": {
        "appName": "Acme"
    }
}</code></pre>

<ul>
    <li><strong>Named interpolation:</strong> <code>{name}</code>, filled with <code>t("home.greeting", { name: "Ada" })</code>.</li>
    <li><strong>List interpolation:</strong> <code>{0}</code>, filled with <code>t("home.position", [12])</code>.</li>
    <li><strong>Linked messages:</strong> <code>@:common.appName</code> reuses another key, so a product name lives in one place.</li>
    <li><strong>Literal interpolation:</strong> <code>{'@'}</code> outputs a literal character. The characters <code>{</code>, <code>}</code>, <code>@</code>, <code>$</code>, and <code>|</code> are part of the syntax, so an email address like <code>support@example.com</code> written plainly will fail to compile.</li>
</ul>

<p>
    For a deeper comparison with the ICU syntax used by next-intl, FormatJS, and most translation platforms, see our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat guide</a>. If you need real ICU in a Vue app, you can plug a custom message compiler into vue-i18n, but most teams are better off sticking with the native format.
</p>

<h3>Rich text without v-html</h3>

<p>
    Never put HTML in a message and render it with <code>v-html</code>; translated strings become an XSS vector. Use the built-in <code>&lt;i18n-t&gt;</code> (Translation) component, which maps named placeholders to slots:
</p>

<pre><code>{
    "signup": {
        "terms": "By signing up you accept our {link}.",
        "termsLink": "terms of service"
    }
}</code></pre>

<pre><code>&lt;template&gt;
    &lt;i18n-t keypath="signup.terms" tag="p"&gt;
        &lt;template #link&gt;
            &lt;RouterLink to="/terms"&gt;{{ t("signup.termsLink") }}&lt;/RouterLink&gt;
        &lt;/template&gt;
    &lt;/i18n-t&gt;
&lt;/template&gt;</code></pre>

<p>Translators can move <code>{link}</code> anywhere in the sentence, and the link still renders as a real component.</p>

<h2>3. Pluralization: Pipes and Plural Rules</h2>

<p>
    vue-i18n plurals separate the forms with a pipe. The number is available inside the message as <code>{count}</code> or <code>{n}</code>:
</p>

<pre><code>{
    "cart": {
        "items": "no items | one item | {count} items"
    }
}</code></pre>

<pre><code>t("cart.items", 0); // "no items"
t("cart.items", 1); // "one item"
t("cart.items", 7); // "7 items"
t("cart.items", { count: 7 }, 7); // explicit named value plus plural count</code></pre>

<p>
    Here is the part many teams miss: <strong>in vue-i18n v11, the default choice rule is English-shaped</strong>. With two forms it picks singular for 1 and plural for everything else; with three forms it treats them as zero, one, and many. That is wrong for Polish, Russian, Czech, Arabic, and many other languages. You must register a rule for each such locale with the <code>pluralRules</code> option. The cleanest way is to build them from <code>Intl.PluralRules</code>, so you rely on CLDR data instead of hand-written modulo arithmetic:
</p>

<pre><code>// src/i18n/plural-rules.ts
import type { PluralizationRule } from "vue-i18n";

// Order the message forms like CLDR categories: one | few | many | other
const CATEGORY_ORDER = ["zero", "one", "two", "few", "many", "other"] as const;

export function cldrRule(locale: string): PluralizationRule {
    const pr = new Intl.PluralRules(locale);
    const categories = CATEGORY_ORDER.filter((c) =&gt;
        pr.resolvedOptions().pluralCategories.includes(c),
    );
    return (choice, choicesLength) =&gt; {
        const index = categories.indexOf(pr.select(choice) as (typeof categories)[number]);
        return Math.min(Math.max(index, 0), choicesLength - 1);
    };
}</code></pre>

<pre><code>// src/i18n.ts
import { cldrRule } from "./i18n/plural-rules";

export const i18n = createI18n({
    legacy: false,
    locale: "en",
    fallbackLocale: "en",
    messages: { en },
    pluralRules: {
        pl: cldrRule("pl"),
        ru: cldrRule("ru"),
        ar: cldrRule("ar"),
    },
});</code></pre>

<pre><code>{
    "cart": {
        "items": "{count} produkt | {count} produkty | {count} produktów | {count} produktu"
    }
}</code></pre>

<p>
    With that rule, the Polish message lists its forms in CLDR order (one, few, many, other), so 1 selects "produkt", 3 selects "produkty", and 5 selects "produktów". Write a comment in your translator brief explaining the order for each language, because a pipe-separated string carries no labels. Automatic <code>Intl.PluralRules</code> selection is planned as a breaking change for vue-i18n v12, so check the release notes when you upgrade.
</p>

<h2>4. Lazy-Load Locale Messages</h2>

<p>
    Bundling every locale into the main chunk means every visitor downloads every language. Ship the default locale eagerly and load the rest on demand with dynamic imports:
</p>

<pre><code>// src/i18n/load.ts
import { nextTick } from "vue";
import { i18n, type AppLocale } from "../i18n";

const loaded = new Set&lt;AppLocale&gt;(["en"]);

export async function setLocale(locale: AppLocale) {
    if (!loaded.has(locale)) {
        const messages = await import(`../locales/${locale}.json`);
        i18n.global.setLocaleMessage(locale, messages.default);
        loaded.add(locale);
    }
    i18n.global.locale.value = locale;
    document.documentElement.lang = locale;
    await nextTick();
}</code></pre>

<p>Call it from a router guard when the locale lives in the URL:</p>

<pre><code>// src/router.ts
router.beforeEach(async (to) =&gt; {
    const locale = to.params.locale as string;
    if (!SUPPORTED_LOCALES.includes(locale as AppLocale)) {
        return `/en${to.fullPath}`;
    }
    await setLocale(locale as AppLocale);
});</code></pre>

<p>
    Vite turns the template-literal import into one chunk per locale. Keep the locale list explicit and validate the parameter before importing it, so a crafted URL cannot request arbitrary files. Also update the <code>lang</code> attribute (and <code>dir</code> for right-to-left locales); screen readers and browser translation features depend on it. Use valid BCP 47 codes for file names, URLs, and attributes; our <a href="/blogs/locale-codes-bcp-47-explained/">locale codes guide</a> covers the rules.
</p>

<figure class="blog-figure"><img src="/blog/inline/vue-i18n-best-practices-1.webp" alt="Flowchart of vue-i18n lazy loading: router.beforeEach checks SUPPORTED_LOCALES and otherwise redirects to /en; setLocale checks loaded.has(locale), dynamically imports ../locales/${locale}.json, calls i18n.global.setLocaleMessage, sets i18n.global.locale.value and document.documentElement.lang, then awaits nextTick" width="1600" height="1130" loading="lazy" decoding="async" /><figcaption>What setLocale() does on a route change: validate, import the locale chunk once, register it, switch.</figcaption></figure>

<h2>5. Format Numbers and Dates with n() and d()</h2>

<p>
    vue-i18n wraps <code>Intl.NumberFormat</code> and <code>Intl.DateTimeFormat</code>. Define named presets per locale once, then use them everywhere:
</p>

<pre><code>// src/i18n/formats.ts
import type { I18nOptions } from "vue-i18n";

export const numberFormats: I18nOptions["numberFormats"] = {
    en: {
        currency: { style: "currency", currency: "USD" },
        percent: { style: "percent", maximumFractionDigits: 1 },
    },
    de: {
        currency: { style: "currency", currency: "EUR" },
        percent: { style: "percent", maximumFractionDigits: 1 },
    },
};

export const datetimeFormats: I18nOptions["datetimeFormats"] = {
    en: {
        short: { year: "numeric", month: "short", day: "numeric" },
        long: { dateStyle: "full", timeStyle: "short" },
    },
    de: {
        short: { year: "numeric", month: "short", day: "numeric" },
        long: { dateStyle: "full", timeStyle: "short" },
    },
};</code></pre>

<pre><code>&lt;script setup lang="ts"&gt;
import { useI18n } from "vue-i18n";

const { n, d } = useI18n();
const props = defineProps&lt;{ total: number; currency: string; createdAt: Date }&gt;();
&lt;/script&gt;

&lt;template&gt;
    &lt;p&gt;{{ d(props.createdAt, "short") }}&lt;/p&gt;
    &lt;!-- the preset supplies the style; the order supplies the currency --&gt;
    &lt;p&gt;{{ n(props.total, { key: "currency", currency: props.currency }) }}&lt;/p&gt;
&lt;/template&gt;</code></pre>

<p>
    Pass both objects to <code>createI18n</code> as <code>numberFormats</code> and <code>datetimeFormats</code>. One trap: a preset that hard-codes <code>currency: "EUR"</code> for German converts nothing; it only changes the symbol. If amounts come in different currencies, take the currency from the data, as above. The underlying <code>Intl</code> behavior is covered in our <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">JavaScript Intl API guide</a>.
</p>

<h2>6. Use SFC &lt;i18n&gt; Blocks Sparingly</h2>

<p>
    With <code>@intlify/unplugin-vue-i18n</code>, a single-file component can carry its own messages in an <code>&lt;i18n&gt;</code> custom block:
</p>

<pre><code>// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
import { fileURLToPath } from "node:url";

export default defineConfig({
    plugins: [
        vue(),
        VueI18nPlugin({
            include: fileURLToPath(new URL("./src/locales/**", import.meta.url)),
        }),
    ],
});</code></pre>

<pre><code>&lt;script setup lang="ts"&gt;
import { useI18n } from "vue-i18n";

// A component with an &lt;i18n&gt; block resolves to local scope implicitly
const { t } = useI18n({ useScope: "local" });
&lt;/script&gt;

&lt;template&gt;
    &lt;button&gt;{{ t("retry") }}&lt;/button&gt;
&lt;/template&gt;

&lt;i18n lang="json"&gt;
{
    "en": { "retry": "Try again" },
    "fr": { "retry": "Réessayer" }
}
&lt;/i18n&gt;</code></pre>

<p>
    Custom blocks are good for reusable library components that must ship their own strings. For application code they scatter translations across hundreds of files that translators and tooling cannot easily find, and the vue-i18n docs note that type-safe resources do not cover custom blocks. Prefer central JSON files per locale (see our <a href="/blogs/json-translation-files-explained/">JSON translation files guide</a> for structure), and keep custom blocks for the exceptions.
</p>

<h2>7. Type Your Message Keys</h2>

<p>
    Treat the source locale as the schema. Declaring it globally gives you autocompletion and compile errors for misspelled keys in <code>t()</code>:
</p>

<pre><code>// src/i18n/vue-i18n.d.ts
import en from "../locales/en.json";

type MessageSchema = typeof en;

declare module "vue-i18n" {
    export interface DefineLocaleMessage extends MessageSchema {}
}</code></pre>

<p>
    The same pattern works for number and date presets through <code>DefineNumberFormat</code> and <code>DefineDateTimeFormat</code>. You can also pass the schema to <code>createI18n&lt;[MessageSchema], "en" | "fr"&gt;(...)</code>; TypeScript then reports a missing locale in the <code>messages</code> object. Both approaches check keys, not translation completeness, so pair them with a CI check (below) that compares every locale file against the source.
</p>

<h2>8. Nuxt: Let @nuxtjs/i18n Do the Heavy Lifting</h2>

<p>In Nuxt 3 and Nuxt 4 projects, install the module instead of wiring vue-i18n by hand:</p>

<pre><code>npx nuxi module add i18n</code></pre>

<pre><code>// nuxt.config.ts
export default defineNuxtConfig({
    modules: ["@nuxtjs/i18n"],
    i18n: {
        baseUrl: "https://example.com",
        defaultLocale: "en",
        strategy: "prefix_except_default",
        locales: [
            { code: "en", language: "en-US", name: "English", file: "en.json" },
            { code: "fr", language: "fr-FR", name: "Français", file: "fr.json" },
            { code: "de", language: "de-DE", name: "Deutsch", file: "de.json" },
        ],
        detectBrowserLanguage: {
            useCookie: true,
            cookieKey: "i18n_redirected",
            redirectOn: "root",
        },
    },
});</code></pre>

<p>
    In v10, locale files live in <code>i18n/locales/</code> at the project root, and every file is loaded lazily, so the old <code>lazy</code> option is gone. Runtime-only vue-i18n options (custom plural rules, formatters, number and date presets) go in <code>i18n/i18n.config.ts</code>:
</p>

<pre><code>// i18n/i18n.config.ts
// formats.ts is the same file as in section 5, moved into i18n/
import { numberFormats, datetimeFormats } from "./formats";

export default defineI18nConfig(() =&gt; ({
    fallbackLocale: "en",
    numberFormats,
    datetimeFormats,
}));</code></pre>

<h3>Choosing a routing strategy</h3>

<table>
    <thead>
        <tr>
            <th>Strategy</th>
            <th>URLs</th>
            <th>Use when</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>prefix_except_default</code> (default)</td>
            <td><code>/about</code>, <code>/fr/about</code></td>
            <td>Adding locales to an existing site without changing current URLs</td>
        </tr>
        <tr>
            <td><code>prefix</code></td>
            <td><code>/en/about</code>, <code>/fr/about</code></td>
            <td>New multilingual sites that want symmetric, explicit URLs</td>
        </tr>
        <tr>
            <td><code>prefix_and_default</code></td>
            <td>Both <code>/about</code> and <code>/en/about</code></td>
            <td>Rarely; it creates duplicates that need canonical tags</td>
        </tr>
        <tr>
            <td><code>no_prefix</code></td>
            <td><code>/about</code> for every locale</td>
            <td>Logged-in apps where SEO per locale does not matter</td>
        </tr>
    </tbody>
</table>

<figure class="blog-figure"><img src="/blog/inline/vue-i18n-best-practices-2.webp" alt="Comparison of Nuxt i18n routing strategies for locales en, fr and de: prefix_except_default gives example.com/about, example.com/fr/about and example.com/de/about; prefix gives example.com/en/about, example.com/fr/about and example.com/de/about" width="1600" height="790" loading="lazy" decoding="async" /><figcaption>prefix_except_default keeps the default locale unprefixed; prefix gives every locale its own segment.</figcaption></figure>

<p>
    Pick once. Changing strategy later changes every URL on the site. Navigation should go through the module's helpers so links keep the current locale:
</p>

<pre><code>&lt;script setup lang="ts"&gt;
const { locale, locales } = useI18n();
const switchLocalePath = useSwitchLocalePath();
&lt;/script&gt;

&lt;template&gt;
    &lt;NuxtLinkLocale to="/pricing"&gt;{{ $t("nav.pricing") }}&lt;/NuxtLinkLocale&gt;

    &lt;NuxtLink
        v-for="l in locales"
        :key="l.code"
        :to="switchLocalePath(l.code)"
        :aria-current="l.code === locale ? 'true' : undefined"
    &gt;
        {{ l.name }}
    &lt;/NuxtLink&gt;
&lt;/template&gt;</code></pre>

<h3>SEO with useLocaleHead</h3>

<p>
    <code>useLocaleHead()</code> returns the <code>lang</code> and <code>dir</code> attributes, <code>hreflang</code> alternate links including <code>x-default</code>, a canonical link, and <code>og:locale</code> tags. It needs <code>baseUrl</code> and a <code>language</code> tag on each locale to produce absolute, valid URLs. Add it once in your root layout:
</p>

<pre><code>&lt;script setup lang="ts"&gt;
const head = useLocaleHead();

useHead(() =&gt; ({
    htmlAttrs: { lang: head.value.htmlAttrs?.lang, dir: head.value.htmlAttrs?.dir },
    link: [...(head.value.link ?? [])],
    meta: [...(head.value.meta ?? [])],
}));
&lt;/script&gt;

&lt;template&gt;
    &lt;NuxtPage /&gt;
&lt;/template&gt;</code></pre>

<p>
    Then confirm the rendered HTML: every locale version should list every other version, and each page's canonical should point at itself, not at the default language. Our <a href="/blogs/hreflang-multilingual-seo-guide/">hreflang and multilingual SEO guide</a> explains what search engines expect.
</p>

<h2>9. Test Translations Like Code</h2>

<p>Mount components with a real i18n instance instead of mocking <code>t</code>, so tests catch broken keys and interpolation:</p>

<pre><code>// tests/CartSummary.spec.ts
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { expect, test } from "vitest";
import CartSummary from "../src/components/CartSummary.vue";
import en from "../src/locales/en.json";

function makeI18n() {
    return createI18n({
        legacy: false,
        locale: "en",
        messages: { en },
        missing: (locale, key) =&gt; {
            throw new Error(`Missing translation: ${locale}.${key}`);
        },
    });
}

test("renders the plural form", () =&gt; {
    const wrapper = mount(CartSummary, {
        props: { count: 3 },
        global: { plugins: [makeI18n()] },
    });
    expect(wrapper.text()).toContain("3 items");
});</code></pre>

<p>Add a key-parity check to CI so a locale cannot silently fall behind the source file:</p>

<pre><code>// scripts/check-locales.ts
import { readFileSync, readdirSync } from "node:fs";

const flatten = (obj: Record&lt;string, unknown&gt;, prefix = ""): string[] =&gt;
    Object.entries(obj).flatMap(([k, v]) =&gt;
        v &amp;&amp; typeof v === "object"
            ? flatten(v as Record&lt;string, unknown&gt;, `${prefix}${k}.`)
            : [`${prefix}${k}`],
    );

const dir = "src/locales";
const source = new Set(flatten(JSON.parse(readFileSync(`${dir}/en.json`, "utf8"))));
let failed = false;

for (const file of readdirSync(dir).filter((f) =&gt; f !== "en.json")) {
    const keys = new Set(flatten(JSON.parse(readFileSync(`${dir}/${file}`, "utf8"))));
    const missing = [...source].filter((k) =&gt; !keys.has(k));
    if (missing.length) {
        failed = true;
        console.error(`${file} is missing ${missing.length} keys:`, missing.slice(0, 10));
    }
}

process.exit(failed ? 1 : 0);</code></pre>

<p>
    Finally, run the UI in a pseudo-locale to find hard-coded strings and layouts that break with longer text. Our <a href="/blogs/pseudo-localization-testing-guide/">pseudo-localization guide</a> shows how to generate one from your source JSON.
</p>

<h2>Common Pitfalls</h2>

<ul>
    <li><strong>Leaving <code>legacy</code> at its default.</strong> The Legacy API is deprecated in v11 and tree-shaken by the bundler plugin. Set <code>legacy: false</code> explicitly.</li>
    <li><strong>Assuming English plural rules work everywhere.</strong> Without <code>pluralRules</code>, Polish and Russian pick the wrong form for numbers like 5 or 22.</li>
    <li><strong>Unescaped special characters.</strong> <code>@</code>, <code>|</code>, <code>{</code>, and <code>}</code> in plain text cause compile errors or silent plural splits. Use literal interpolation such as <code>{'@'}</code>.</li>
    <li><strong>Concatenating translations.</strong> <code>t("hello") + " " + name</code> fixes word order to English. Put the variable inside the message.</li>
    <li><strong>HTML in messages with <code>v-html</code>.</strong> Use <code>&lt;i18n-t&gt;</code> slots instead.</li>
    <li><strong>A shared i18n instance in SSR.</strong> In custom server rendering setups, create the instance inside your app factory so concurrent requests cannot change each other's locale. Nuxt handles this for you.</li>
    <li><strong>Calling <code>useI18n</code> with local scope twice in one component.</strong> vue-i18n warns and the second call does not behave as expected; call it once and destructure what you need.</li>
    <li><strong>Destructuring <code>locale</code> and then reassigning it.</strong> <code>locale</code> is a ref; write to <code>locale.value</code>, or use <code>setLocale</code> in Nuxt so lazy messages load first.</li>
</ul>

<h2>Frequently Asked Questions</h2>

<h3>Should I use the Composition API or the Legacy API with vue-i18n?</h3>
<p>
    Use the Composition API. Create the instance with createI18n({ legacy: false }) and call useI18n() in components. The Legacy API is deprecated in vue-i18n v11 and is scheduled for removal in v12, and the tc() and $tc() functions have already been removed.
</p>

<h3>Does vue-i18n use ICU MessageFormat?</h3>
<p>
    No. vue-i18n has its own syntax: {name} for named values, {0} for list values, @:key for linked messages, and pipe-separated plural forms such as "one item | {count} items". ICU plural blocks from other libraries will not work without a custom message compiler.
</p>

<h3>How do I handle plurals for languages like Polish or Russian in vue-i18n?</h3>
<p>
    In vue-i18n v11, register a function for each locale with the pluralRules option of createI18n. Building the function on Intl.PluralRules lets you list forms in CLDR order (one, few, many, other) instead of writing modulo arithmetic by hand.
</p>

<h3>How do I lazy-load translations in Vue and Nuxt?</h3>
<p>
    In Vue, load the JSON with a dynamic import, register it with i18n.global.setLocaleMessage(), then set i18n.global.locale.value. In Nuxt, @nuxtjs/i18n v10 lazy-loads every locale file listed in the locales option automatically.
</p>

<h3>Which @nuxtjs/i18n strategy is best for SEO?</h3>
<p>
    Both prefix and prefix_except_default give each language its own crawlable URL. Use prefix_except_default to keep existing URLs for the default language, or prefix for symmetric URLs on a new site. Combine either with useLocaleHead(), baseUrl, and a language tag on each locale to output hreflang and canonical links.
</p>

<h2>Wrapping Up</h2>

<p>
    Good Vue localization is mostly a set of early decisions: Composition API from day one, one JSON file per locale with typed keys, explicit plural rules for every language you ship, lazy loading, and <code>n()</code> and <code>d()</code> for anything numeric. In Nuxt, the module covers routing, lazy loading, and SEO tags, so your job is choosing a URL strategy and keeping the locale files complete. If you want help keeping those files in sync, <a href="https://docs.babelize.co/features/file-formats" target="_blank" rel="noopener noreferrer">Babelize generates per-locale JSON files</a> that vue-i18n can load directly.
</p>
---
- [All articles](https://www.babelize.co/blogs)