9 min read
It is said that: If you talk to a man in a language he understands, that goes to his head. If you talk to him in his language, that goes to his heart. ~ Nelson Mandela. Software is and should always be accessible, thus the interfaces created so that it can be used in any language. There are various approaches to this, the primary and most recognized approaches are internationalization (i18n) and localization (l10n) - they aren’t the same but are used interchangeably. For this deep dive, we are going to focus on internationalization.
The easiest way to explain or define this term is this: internationalization enables a piece of software to handle multiple language environments, localization enables a piece of software to support a specific regional language environment.
This guide will show you how to implement internationalization in your Vue 3 application using vue-i18n
. You’ll learn how to structure translations, handle dynamic content, and manage language switching - all within the Vue 3 composition API context and all explained in true developer style.
<!-- App.vue -->
<template>
<div>
<h1>{{ t('welcome') }}</h1>
<p v-if="isLoading">{{ t('loading') }}</p>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
// i18n.js
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
locale: 'en', // default language
fallbackLocale: 'en', // fallback for when things go wrong
messages: {
en: {
welcome: 'Welcome to the Matrix',
errors: {
404: 'Page is hiding in another castle',
500: 'Server is having a moment'
},
loading: 'Convincing electrons to do work...'
},
es: {
welcome: 'Bienvenido a la Matrix',
errors: {
404: 'La página está escondida en otro castillo',
500: 'El servidor está teniendo un momento'
},
loading: 'Convenciendo a los electrones de trabajar...'
}
}
})
export default i18n
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n'
const app = createApp(App)
app.use(i18n)
app.mount('#app')
<!-- LocaleSwitcher.vue -->
<template>
<div class="lang-switcher">
<select v-model="locale" @change="switchLanguage">
<option
v-for="(name, lang) in languages"
:key="lang"
:value="lang"
>
{{ name }}
</option>
</select>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { locale, t } = useI18n()
const languages = {
en: "🇬🇧 English (Compile-time)",
es: "🇪🇸 Español (Tiempo de compilación)"
}
const switchLanguage = () => {
console.log(t('debug.langSwitch', { lang: locale.value }))
}
</script>
<style scoped>
.lang-switcher {
margin: 1rem 0;
}
.lang-switcher select {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
</style>
<!-- DynamicContent.vue -->
<template>
<div>
<p>{{ t('greetings.morning', { name: developerName }) }}</p>
<p>{{ t('bugs.count', { count: bugCount }) }}</p>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
const { t } = useI18n()
const developerName = ref('Code Wizard')
const bugCount = ref(42)
</script>
// translations/messages.js
export default {
en: {
greetings: {
morning: "Good morning {name}, have you tried turning it off and on again?",
night: "Still coding at {time}? Same here!",
weekend: "It's {day}! Time to debug in pajamas!"
},
bugs: {
count: "You have {count} bugs to fix"
},
debug: {
langSwitch: "Language switched to {lang}"
}
},
es: {
greetings: {
morning: "Buenos días {name}, ¿has intentado apagarlo y encenderlo de nuevo?",
night: "¿Aún programando a las {time}? ¡Yo también!",
weekend: "¡Es {day}! ¡Hora de depurar en pijama!"
},
bugs: {
count: "Tienes {count} errores que arreglar"
},
debug: {
langSwitch: "Idioma cambiado a {lang}"
}
}
}
<!-- PluralExample.vue -->
<template>
<div>
<p>{{ $tc('debug.bugs', bugCount, { count: bugCount }) }}</p>
<p>{{ $tc('coffee.cups', coffeeCount, { count: coffeeCount }) }}</p>
<div class="controls">
<button @click="bugCount++">Add Bug</button>
<button @click="bugCount = Math.max(0, bugCount - 1)">Fix Bug</button>
<button @click="coffeeCount++">More Coffee</button>
<button @click="coffeeCount = Math.max(0, coffeeCount - 1)">Drink Coffee</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const bugCount = ref(0)
const coffeeCount = ref(3)
</script>
<style scoped>
.controls {
margin-top: 1rem;
}
.controls button {
margin: 0.25rem;
padding: 0.5rem 1rem;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.controls button:hover {
background: #005a9e;
}
</style>
// translations/plural.js
export default {
en: {
debug: {
bugs: "No bugs found (suspicious...) | Found one bug (there's definitely more) | Found {count} bugs (and counting...)"
},
coffee: {
cups: "Emergency: No coffee remaining! | Last coffee warning! | {count} cups of coffee remaining"
}
},
es: {
debug: {
bugs: "No se encontraron errores (sospechoso...) | Se encontró un error (definitivamente hay más) | Se encontraron {count} errores (y contando...)"
},
coffee: {
cups: "¡Emergencia: No queda café! | ¡Última advertencia de café! | {count} tazas de café restantes"
}
}
}
// translations/developer-life.js
export const developerLife = {
en: {
statusMessages: {
compiling: "Converting caffeine to code...",
debugging: "Playing hide and seek with bugs...",
deploying: "Crossing fingers and deploying...",
success: "It works! Don't touch anything!",
error: "Error: Success condition not found"
},
excuses: {
deadline: "The deadline was more of a suggestion",
bug: "It's not a bug, it's an undocumented feature",
testing: "But it works on my machine!"
}
},
es: {
statusMessages: {
compiling: "Convirtiendo cafeína en código...",
debugging: "Jugando al escondite con los errores...",
deploying: "Cruzando los dedos y desplegando...",
success: "¡Funciona! ¡No toques nada!",
error: "Error: Condición de éxito no encontrada"
},
excuses: {
deadline: "La fecha límite era más una sugerencia",
bug: "No es un error, es una característica no documentada",
testing: "¡Pero funciona en mi máquina!"
}
}
}
// translations/error-messages.js
export const errorMessages = {
en: {
errors: {
network: "Internet decided to take a coffee break",
database: "Database is practicing social distancing",
validation: "Your code is valid but my heart says no"
}
},
es: {
errors: {
network: "Internet decidió tomar un descanso de café",
database: "La base de datos está practicando distanciamiento social",
validation: "Tu código es válido pero mi corazón dice que no"
}
}
}
<!-- TranslationExample.vue -->
<template>
<div>
<button @click="showRandomExcuse">
Generate Developer Excuse
</button>
<p v-if="currentExcuse">{{ currentExcuse }}</p>
<p>{{ debugStatus.message }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useDevTranslations } from '../composables/useDevTranslations'
const { getRandomExcuse, debugStatus } = useDevTranslations()
const currentExcuse = ref('')
const showRandomExcuse = () => {
currentExcuse.value = getRandomExcuse()
}
</script>
<style scoped>
button {
background: #42b883;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
margin-bottom: 1rem;
}
button:hover {
background: #369870;
}
p {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f8f9fa;
border-left: 4px solid #42b883;
}
</style>
// composables/useDevTranslations.js
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
export function useDevTranslations() {
const { t, locale } = useI18n()
const getRandomExcuse = () => {
const excuses = ['deadline', 'bug', 'testing']
const randomExcuse = excuses[Math.floor(Math.random() * excuses.length)]
return t(`excuses.${randomExcuse}`)
}
const getCoffeeStatus = (cups) => {
return t('coffee.cups', cups)
}
const getStatusMessage = (status) => {
return t(`statusMessages.${status}`)
}
const debugStatus = computed(() => ({
message: t('statusMessages.debugging'),
excuse: getRandomExcuse()
}))
return {
getRandomExcuse,
getCoffeeStatus,
getStatusMessage,
debugStatus,
currentLocale: locale
}
}
<!-- App.vue -->
<template>
<div class="app">
<header>
<LocaleSwitcher />
<h1>{{ t('title') }}</h1>
<p>{{ t('welcome') }}</p>
</header>
<main>
<DynamicContent />
<PluralExample />
<TranslationExample />
</main>
</div>
</template>
<script setup>
import LocaleSwitcher from './components/LocaleSwitcher.vue'
import DynamicContent from './components/DynamicContent.vue'
import PluralExample from './components/PluralExample.vue'
import TranslationExample from './components/TranslationExample.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #eee;
}
main {
display: grid;
gap: 2rem;
}
</style>
// Complete i18n setup with all translations
import { createI18n } from 'vue-i18n'
import { developerLife } from './translations/developer-life'
import { errorMessages } from './translations/error-messages'
import messages from './translations/messages'
import plural from './translations/plural'
// Merge all translation objects
const mergedMessages = {
en: {
title: 'Vue-elingual: Multi-language Magic',
welcome: 'Welcome to the world of Vue.js internationalization!',
...messages.en,
...plural.en,
...developerLife.en,
...errorMessages.en
},
es: {
title: 'Vue-elingual: Magia Multi-idioma',
welcome: '¡Bienvenido al mundo de la internacionalización de Vue.js!',
...messages.es,
...plural.es,
...developerLife.es,
...errorMessages.es
}
}
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: mergedMessages,
legacy: false, // Use composition API mode
globalInjection: true // Allow $t in templates
})
export default i18n
Keep translations in separate files by feature/module or sort translations like you sort your coffee cups - by importance.
Use TypeScript with vue-i18n for better type checking or because future you will thank present you.
// types/i18n.ts
export interface LocaleMessages {
welcome: string
errors: {
404: string
500: string
}
loading: string
}
Load language files on demand to improve initial load time or like one should brew coffee - on demand.
// Lazy loading example
const loadLocaleMessages = async (locale) => {
const messages = await import(`./locales/${locale}.json`)
i18n.global.setLocaleMessage(locale, messages.default)
}
legacy: false
option for better performanceConsider using Pinia to manage language preferences or because global state is like coffee - best when managed properly.
Write tests for your translations using Vue Test Utils or they’ll test your patience.
// Example test
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
const i18n = createI18n({
locale: 'en',
messages: {
en: { welcome: 'Welcome' }
}
})
test('renders welcome message', () => {
const wrapper = mount(App, {
global: {
plugins: [i18n]
}
})
expect(wrapper.text()).toContain('Welcome')
})
When using this in an Astro project, consider:
Implementing internationalization in Vue 3 applications doesn’t have to be a nightmare. With vue-i18n and the composition API, you can create maintainable, scalable multilingual applications that speak your users’ language - literally and figuratively.
Remember: good internationalization is like good coffee - it takes time to set up properly, but once you do, everyone appreciates it.