
i18n and l10n in Vue/Nuxt projects from a real-world perspective
Andrea Di Toro
Vue & Nuxt Senior Developer & Consultant
Internationalization (i18n) and localization (l10n) are often seen as minor technical details, when in reality they involve architectural decisions with a direct impact on SEO, scalability, and maintenance.
Most of the problems I've seen in real projects share a common origin: adding i18n as an afterthought, which generates costly changes that clash with the existing architecture.
This article proposes a decision framework based on key questions that will help you define the right i18n strategy from the beginning, allowing you to scale without traumatic rewrites.
To define a strategy, these are the questions I always ask in early phases. The answers will give us guidance on the type of solution we need to implement.
There is no universal correct answer. The strategy depends on these answers, not on whichever library is trendy.
If you only need one language today, the smartest strategy is to prepare the ground without over-engineering. Install @nuxtjs/i18n and move all literals to JSON files from the start:
// nuxt.config.ts - Minimal configuration to start
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
defaultLocale: 'en',
lazy: true,
langDir: 'locales/',
},
})
For larger projects, you can organize translations by module or section:
app/
├── locales/
│ └── en/
│ ├── common.json # Shared texts (navigation, buttons)
│ ├── landing.json # Main page
│ ├── dashboard.json # User panel
│ └── auth.json # Login, registration, errors
// nuxt.config.ts - Configuration with multiple files
i18n: {
locales: [
{
code: 'en',
language: 'en-US',
files: ['en/common.json', 'en/landing.json', 'en/dashboard.json', 'en/auth.json'],
name: 'English',
},
]
}
Language files live inside the repository and can only be modified by developers. This may seem limiting, but it's the simplest and safest option for small technical teams.
With this foundation, the day you need to add, for example, Spanish, you just have to:
locales/es/ folder with the same files// Adding a second language is trivial
locales: [
{ code: 'en', language: 'en-US', files: ['en/common.json', ...], name: 'English' },
{ code: 'es', language: 'es-ES', files: ['es/common.json', ...], name: 'Español' }
]
If the texts live in the database or in the backend, outside the frontend application, we need to do more than just configure Nuxt.js with the i18n module. Important decisions must be made that can affect the application's architecture. If you're in this situation, I propose the following possibilities:
The backend sends translation keys instead of texts. The frontend has all literals centralized:
// Backend response
{
"status": "error",
"message_key": "errors.payment_failed", // Key, not text
"code": "PAYMENT_001"
}
// locales/en/errors.json
{
"errors": {
"payment_failed": "The payment could not be processed. Please try again.",
"session_expired": "Your session has expired. Please log in again.",
"not_found": "The requested resource does not exist."
}
}
Advantages: All texts in one place, the backend doesn't worry about i18n. Disadvantages: Requires coordination between teams to keep keys synchronized.
The backend has its own i18n system and sends already translated texts. Useful when the backend serves multiple clients (web, mobile, third parties):
// The frontend sends the language in each request
const { data } = await useFetch('/api/products', {
headers: {
'Accept-Language': locale.value, // 'en', 'es', etc.
},
})
// Or as a query param
const { data } = await useFetch(`/api/products?lang=${locale.value}`)
Advantages: The backend part is decoupled from the front and can be used independently. Disadvantages: We'll have several places to translate, which can make managing all translations complicated.
For dynamic content that lives in the database, you need a data model that supports multiple languages from the initial design.
If you use a CMS like Strapi, activate the i18n functionality from the beginning, even if you only have one language today. Strapi has native support for internationalization and activating it later involves data migrations and changes to content-type structure that can be costly. Here's an example in Nuxt.js with Strapi:
// Fetch of localized content from Strapi
const { locale } = useI18n()
const { findOne } = useStrapi()
const { data: article } = await useAsyncData(`article-${slug}-${locale.value}`, () =>
findOne('articles', slug, {
locale: locale.value,
populate: ['cover', 'author', 'category'],
})
)
If you design your own database (SQL or NoSQL), plan from the start a structure that supports multiple languages. The most common pattern is to create a languages table and from other tables you can apply foreign keys to the corresponding language.
In my experience, the most complicated thing is migrating databases where at the beginning of the project they didn't take language into account. It can be a real headache because you not only have to modify the database, but the entire project: queries, AJAX calls passing parameters, JSON responses, managing the default language when the requested translation doesn't exist, etc.
This is another important aspect to consider. If the project is a private dashboard where you only access after a login and has no SEO implications, things are very simple: you don't have to do anything more than what was previously mentioned.
International SEO requires that each resource (page) has its own indexable URL. Google needs to be able to crawl and show the correct version to each user. Therefore, the same page in different languages must have different URLs, and the corresponding language must be reflected in the URL.
URL strategies in @nuxtjs/i18n:
// nuxt.config.ts
i18n: {
// Option 1: Subdirectories (recommended)
// example.com/en/products, example.com/es/productos
strategy: 'prefix',
// Option 2: No prefix for default language
// example.com/products, example.com/es/productos
strategy: 'prefix_except_default',
// Option 3: Subdomains (requires DNS configuration)
// en.example.com, es.example.com
differentDomains: true,
locales: [
{ code: 'en', domain: 'example.com' },
{ code: 'es', domain: 'es.example.com' }
]
}
Translated URLs (localized slugs):
// nuxt.config.ts
i18n: {
customRoutes: 'config',
pages: {
'products/index': {
en: '/products',
es: '/productos',
de: '/produkte'
},
'about': {
en: '/about-us',
es: '/nosotros',
de: '/uber-uns'
},
'contact': {
en: '/contact',
es: '/contacto',
de: '/kontakt'
}
}
}
Applying these changes to a project already in production and indexed by Google can be problematic. Imagine a project with hundreds of URLs already indexed. If you change the URL structure, you have to manage all 301 redirects. It can cause temporary loss of indexing and worse ranking, even if temporary.
// Example of redirects in nuxt.config.ts for migration
routeRules: {
// Redirect old URLs to new ones with language
'/products': { redirect: '/en/products' },
'/about': { redirect: '/en/about-us' },
'/contact': { redirect: '/en/contact' }
}
This question determines if the JSON files live in the repository or if you need an external solution to facilitate the other team to intervene and modify the texts. In these cases it can be useful to do the following:
The texts no longer live in the same project repository, but we can move them to an external platform specialized in translation management. They typically help teams collaborate, suggest pending translations, detect missing ones, spelling errors, and have AI integrations.
Localization platform (Crowdin, Lokalise, Phrase, Weblate)
These tools automatically synchronize with your repository. You need to configure the CI/CD flow for them to work correctly. The flow would be:
en.json and pushes to a branch in the repository.CMS as translation source
You can use a content manager to translate texts. They typically have i18n plugins, user permissions, and the CMS itself can be where all the texts live. If you already use Strapi, you can create a content-type for UI translations:
// Strapi: content-type "ui-translation"
{
"key": "hero.title", // Unique key
"en": "Welcome", // English text
"es": "Bienvenido", // Spanish text
"context": "Main title" // Context for translators
}
External repository as npm dependency (compile-time)
Extracting translations to an independent repository allows the translation team to work without touching the application code. Once translations are finished, we can include them by adding the repository as a dependency; with each npm install, the latest translations are downloaded again.
# Translation repo structure
my-app-i18n/
├── package.json
├── en.json
├── es.json
└── de.json
// package.json of translation repo
{
"name": "@mycompany/app-translations",
"version": "1.2.0",
"main": "index.js",
"files": ["*.json"]
}
In your Nuxt application, you install translations as a dependency:
npm install @mycompany/app-translations
// nuxt.config.ts
i18n: {
locales: [
{ code: 'en', language: 'en-US', file: 'en.json' },
{ code: 'es', language: 'es-ES', file: 'es.json' }
],
langDir: 'node_modules/@mycompany/app-translations/'
}
The workflow would be as follows:
npm update @mycompany/app-translations).Translations loaded at runtime from external URL (run-time)
This is the most powerful option for translation teams: changes are seen in production without needing to redeploy the application. When the content team has finished, with a CI/CD flow we can deploy the new files to a server or CDN and load them dynamically in the application.
Workflow (real-time changes):
en.json in the translations repo.Advantages of the runtime approach:
Considerations:
This is the difference between internationalization (i18n) and localization (l10n). If you only translate texts literally, you're doing i18n. If you adapt content, prices, features, and tone by market, you're doing l10n.
The key difference: i18n changes the language, l10n changes the experience.
Imagine an e-commerce in English that sells in the US and UK. Although the language is the same, the user experience should be different:
This means that the locale is no longer just for translating: it becomes a business variable that determines what to show, what to hide, and how to format.
These configurations per market can be centralized in the translation files themselves:
// locales/en-US.json
{
"config": {
"currency": "USD",
"currency_symbol": "$",
"payment_methods": ["card", "venmo", "paypal"],
"express_delivery": "2-3 days"
}
}
// locales/en-GB.json
{
"config": {
"currency": "GBP",
"currency_symbol": "£",
"payment_methods": ["card", "apple_pay", "transfer"],
"express_delivery": "Next day"
}
}
This way you can access these configurations from any component using $t('config.currency') or $t('config.payment_methods').
You need country variants when:
If your case is simpler (same currency, same market, only literal translations), you don't need to complicate yourself with country variants.
Internationalization and localization (i18n and l10n) in Nuxt is not just a technical decision. It's a combination of architecture, content, SEO, and business.
From my experience, projects that have a correct approach from the beginning have fewer problems scaling, adding languages, and internationalizing. The questions in this article are not theoretical: each answer determines a different technical strategy.
If you're planning a multilingual project in Vue or Nuxt, it's worth stopping, thinking, and designing before installing the first library.
If you're looking for someone who brings structure, judgment and real experience, let's talk.