Localize a Vue app: complete guide with Localingos
Vue 3 with the Composition API + vue-i18n is the de facto i18n stack in 2026, and it pairs cleanly with Localingos's automated translation pipeline. This guide takes a fresh Vue 3 app to fully localized in about 20 minutes, covering setup, runtime usage, pluralization, lazy locale loading, and CI integration.
Step 1 — Install
npm install vue-i18n@11
npm install -g localingos
Vue 3 needs vue-i18n@9 or later; install v11, since v9 and v10 are no longer supported upstream and npm will warn on install. Earlier majors only support Vue 2.
Step 2 — Wire vue-i18n
src/i18n/index.ts:
import { createI18n } from 'vue-i18n';
import enUS from './en-US.json';
import esES from './es-ES.json';
import deDE from './de-DE.json';
import frFR from './fr-FR.json';
// Use the full BCP 47 codes Localingos uses. The CLI names every file after the
// project's target-locale code, so these keys, your filenames and your <option>
// values all line up with no translation layer in between.
export const SUPPORTED = ['en-US', 'es-ES', 'de-DE', 'fr-FR'] as const;
export const i18n = createI18n({
legacy: false, // use Composition API
locale: detectLocale(),
fallbackLocale: 'en-US',
messages: { 'en-US': enUS, 'es-ES': esES, 'de-DE': deDE, 'fr-FR': frFR },
});
function detectLocale(): string {
const saved = localStorage.getItem('locale');
if (saved && SUPPORTED.includes(saved as typeof SUPPORTED[number])) return saved;
// Match the full tag first, then the language subtag. Truncating to the
// subtag unconditionally would ask for a locale like "en", which is not a
// code Localingos writes a file for — every visitor would fall back.
const nav = navigator.language ?? 'en-US';
const exact = SUPPORTED.find(l => l.toLowerCase() === nav.toLowerCase());
if (exact) return exact;
const lang = nav.split('-')[0].toLowerCase();
return SUPPORTED.find(l => l.split('-')[0].toLowerCase() === lang) ?? 'en-US';
}
src/main.ts:
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './i18n';
createApp(App).use(i18n).mount('#app');
src/i18n/en-US.json — your source of truth:
{
"welcome": "Welcome, {name}",
"cart": "no items in cart | one item in cart | {count} items in cart"
}
Note the pipe syntax for vue-i18n's pluralization — zero | one | other.
Step 3 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "json-nested",
"sourceFile": "src/i18n/en-US.json",
"outputDir": "src/i18n",
"outputPattern": "{locale}.json"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
Three things worth knowing:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"home.title"as one top-level key. Those are the two supported values.- Target locales are not configured here. They belong to the project itself — set them in the dashboard under Projects → Edit → Locales → Update Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g.
es-ES,pt-BR,zh-TW. - Placeholder preservation is automatic. Localingos extracts placeholders from the source string —
{{name}},{name},${count},%s,%d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.
That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.
Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.
Note that vue-i18n uses {variable} (single curly braces) by default, not {{variable}} like react-i18next. Both are detected and preserved automatically — there is no placeholders setting to keep in sync.
Translations land in src/i18n/. Commit them.
Step 4 — Use in components
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
defineProps<{ userName: string; itemCount: number }>();
</script>
<template>
<header>
<h1>{{ t('welcome', { name: userName }) }}</h1>
<p>{{ t('cart', itemCount) }}</p>
</header>
</template>
vue-i18n selects among the pipe-separated forms you provide, by index — it does not apply CLDR rules unless you give it per-locale pluralRules functions in createI18n. And translation returns the same number of forms you sent. So for Russian's three forms or Arabic's six you must author those forms in your source string and supply matching pluralRules; a two-form English source will render incorrect counts in those languages otherwise.
Plurals need a deliberate decision. Localingos translates the forms you send it. It does not add plural categories your source language doesn't have — a string with two forms comes back with two forms, even in a language that needs four (Polish) or six (Welsh). Author every form your target languages require in your source file and let your i18n library select among them at runtime, or keep count-bearing copy out of translation and format numbers separately.
Step 5 — Language switcher
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
const LOCALES: Record<string, string> = {
'en-US': 'English',
'es-ES': 'Español',
'de-DE': 'Deutsch',
'fr-FR': 'Français',
};
function switchLocale(newLocale: string) {
locale.value = newLocale;
localStorage.setItem('locale', newLocale);
}
</script>
<template>
<select :value="locale" @change="e => switchLocale((e.target as HTMLSelectElement).value)">
<option v-for="(label, code) in LOCALES" :key="code" :value="code">
{{ label }}
</option>
</select>
</template>
Step 6 — Lazy loading locales
If you ship 10+ languages, bundling every locale into the initial JS is wasteful. Switch to dynamic imports:
import { createI18n } from 'vue-i18n';
const SUPPORTED = ['en-US', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP', 'pt-BR', 'zh-CN', 'ko-KR'];
export const i18n = createI18n({
legacy: false,
locale: 'en-US',
fallbackLocale: 'en-US',
messages: {},
});
export async function loadLocale(locale: string) {
if (!SUPPORTED.includes(locale)) return;
// Path must match outputDir in localingos.config.json.
const messages = await import(`./${locale}.json`);
i18n.global.setLocaleMessage(locale, messages.default);
i18n.global.locale.value = locale;
}
// On app start
loadLocale(detectLocale());
Each locale becomes its own chunk; only the active one is fetched on demand.
Step 7 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['src/i18n/en-US.json'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations'
commit-message: 'chore(i18n): sync translations'
Production checklist
- SSR-compatible. vue-i18n works with Nuxt out of the box; for Vite + custom SSR setups, ensure i18n state is per-request (not shared across requests).
- RTL support. Bind
diron<html>from the active locale, matching the language subtag since your codes are full BCP 47:['ar','he','fa','ur'].includes(locale.split('-')[0]). Use CSS logical properties (margin-inline-end) or the layout mirrors incorrectly. - Number/date formatting. vue-i18n has built-in helpers (
$n(),$d()) — much cleaner than rolling Intl.NumberFormat by hand. - Type-safe keys. Generate types from
en-US.jsonsot('missingKey')is a compile error.
Wrap up
Your Vue 3 app now handles 56 locales with pluralization, lazy loading, and persistent user preference. Translations stay current through CI. Adding the next language is one change on the project in the dashboard (Projects → Edit → Locales) and a redeploy.
Free tier: 5,000 words, counted once per target locale — so a small Vue app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.