aditoro.com
Internationalization and localization strategies

Internationalization and Localization Strategies for Vue / Nuxt

i18n and l10n in Vue/Nuxt projects from a real-world perspective

Vue.jsNuxt.jsi18nl10n
January 20, 2025
Andrea Di Toro

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.

Table of Contents

  1. Key questions to define a correct strategy
  2. How many languages do we need?
  3. Do the texts live in the front, backend, or database?
  4. Is it a public project with SEO or a private dashboard?
  5. Who manages the translations?
  6. Will there be content variations by country?
  7. Conclusion

Key questions to define a correct strategy

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.

  • How many languages do we need today and how many might we need in the future?
  • Do the texts live in the front, backend, or database?
  • Is it a public project with SEO or a private dashboard available only after login?
  • Who manages the translations: developers or is there a content team?
  • Will there be content variations by country or just literal translation?

There is no universal correct answer. The strategy depends on these answers, not on whichever library is trendy.

How many languages do we need today and how many might we need in the future?

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:

  1. Create the locales/es/ folder with the same files
  2. Add the locale to the configuration
  3. Translate the JSON 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' }
]

Do the texts live in the front, backend, or database?

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.

Backend manages its own translations

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.

Content in database (CMS, products, articles)

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.

Is it a public project with SEO or a private dashboard available only after login?

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.

For public projects with SEO

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' }
}

Who manages the translations: developers or a content team?

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:

  • Move the texts to an external repository to manage them with an appropriate tool.
  • Plan a strategy to load the texts into the project.

Moving texts to external repositories and tools

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:

  1. The developer adds a new key in en.json and pushes to a branch in the repository.
  2. CI automatically uploads the new files to Weblate or the platform.
  3. Translators work on the platform and modify the file contents through Crowdin.
  4. When finished, Crowdin sends the changes (commit, PR, depends on the tool and configuration).
  5. CI downloads the translations and creates a PR.

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:

  1. Translators edit the JSON files in the translations repo with an external tool.
  2. Merge is done in the translations repo and CI is triggered which publishes a new version.
  3. The app updates the dependency (npm update @mycompany/app-translations).
  4. New deploy with updated 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):

  1. The translator edits en.json in the translations repo.
  2. Merge to main → GitHub Actions deploys to GitHub Pages.
  3. In 1-2 minutes, users see the updated translations without app redeploy.

Advantages of the runtime approach:

  • Updated translations without app CI/CD.
  • The translation team is completely autonomous.
  • Instant rollback (revert commit in translations repo).
  • Possibility of A/B testing of texts.

Considerations:

  • Adds initial latency (mitigable with cache).
  • You need to manage network errors.
  • Translations should be on a fast CDN.

Will there be content variations by country or just literal translation?

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.

Example: same language, different market

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:

  • Currency: In the US you show dollars ($), in the UK pounds (£).
  • Payment methods: In the US you offer Venmo and PayPal. In the UK, Apple Pay and bank transfer.
  • Delivery times: In the US express shipping is 2-3 days. In the UK, next day.
  • Price formatting: "$1,234.50" vs "£1,234.50" (same format but different currency).

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.

Where to store these differences

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').

When you need real localization

You need country variants when:

  • You sell in different markets with different currencies, taxes, or logistics.
  • There are regulatory differences: privacy policies, legal terms, GDPR in Europe vs US.
  • The same language has cultural differences: US English and UK English share the language but differ in vocabulary and expectations.

If your case is simpler (same currency, same market, only literal translations), you don't need to complicate yourself with country variants.

Conclusion

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.

Do you have a project or need technical support?

If you're looking for someone who brings structure, judgment and real experience, let's talk.