This content originally appeared on DEV Community and was authored by Waffeu Rayn
Building a robust internationalization (i18n) system is challenging. Many developers start with a simple, monolithic translation file, but this quickly leads to performance issues and maintenance nightmares as the project grows. A better approach is to combine the efficiency of lazy-loading with a well-structured custom function to create a solution that is both performant and easy to manage, regardless of the framework you’re using.
This article outlines a hybrid i18n strategy that handles dynamic translation loading, ensuring your application remains fast, organized, and type-safe.
Methodologies for Internationalization
The choice of internationalization (i18n) method depends on your project’s size and complexity. Here are three common approaches, along with a detailed explanation of their trade-offs.
1. The Monolithic Method (Small Projects)
This is the simplest approach. All translations for all languages are stored in a single, large JSON file.
- How it works: You import a single file at the start of your app. The file is structured with language keys at the top level.
- Pros: Easy to set up and great for small projects or single-language apps. All translations are immediately available.
- Cons: Not scalable. The file becomes too large, increasing initial load time and making it difficult for multiple developers to work on translations simultaneously without frequent merge conflicts.
2. The Language-Based Method (Medium Projects)
This method separates translations by language, so you have one file per language.
- How it works: Your application loads a single language file based on the user’s locale. This is often done at the router level or in a central store.
- Pros: Reduces the initial bundle size compared to the monolithic method, as the user only downloads one language. Better for version control and collaboration.
- Cons: Still inefficient for very large applications. Every user downloads the entire language file, even if they only visit a few pages.
3. The Hybrid Method (Large Projects)
This is the pattern we’ll focus on. It combines the benefits of the other two methods by using a custom function (known as a composable in Vue.js or a custom hook in React).
Why the Hybrid Method Is Superior
The hybrid method solves the core problems of the monolithic and language-based approaches by addressing performance and maintainability.
Performance: The Problem of Initial Load
In a large application with hundreds of pages and components, a monolithic or language-based translation file can grow to several megabytes. When a user first visits your website, they have to download this entire file before they can see any text. This is a massive waste of bandwidth and time, especially on mobile networks, and it directly impacts your Core Web Vitals scores.
The hybrid method avoids this by only loading the translations required for the current page. The common translations, which are essential everywhere, are a small fraction of the total. The rest are dynamically imported only when needed, significantly reducing the initial bundle size and improving perceived load time.
Maintainability: The Problem of Monoliths
Imagine a team of ten developers working on a large application. If every developer needs to add new translations to a single en.json
file, they will constantly run into merge conflicts. These conflicts are a huge drain on productivity and can introduce bugs when translation keys are accidentally overwritten.
By organizing translations by feature (e.g., home.json
, products.json
), each developer can work on their feature’s translation file without affecting others. This dramatically improves collaboration and makes the entire translation process more manageable.
The Advantage of a Global common.json
The common.json
file serves as a single source of truth for all frequently used strings across your entire application. This offers two key benefits:
- Centralization: It ensures consistency for common phrases like “Submit,” “Cancel,” or “Search.” You only have to translate these once, which reduces effort and prevents human error.
- Immediate Availability: Since it’s preloaded, any part of your application can instantly access these translations without waiting for a dynamic import.
The Core Function: A Custom Composable
The power of this solution lies in a custom function that handles all the heavy lifting. This self-contained function provides a clean API for requesting translations for a specific feature. It performs these key functions:
-
Dynamic Loading: When called with a namespace (e.g.,
'home'
), it automatically imports the corresponding translation file for the current language. -
Built-in Caching: It uses a
Map
to cache promises, preventing redundant network requests if the same namespace is requested multiple times. -
Reactive State Management: It exposes an
isReady
flag, a reactive state that indicates when the translations have finished loading. This allows your component to show a loading state before rendering, preventing translation keys from appearing. -
Global Integration: After a translation file is loaded, the function merges it into the global i18n instance. This ensures any component can access the new translations using the standard
t()
function.
This custom function keeps your components clean and focused on their core logic, abstracting away all the complex i18n management.
A significant advantage is how this pattern works with child components. Once a parent component (like a page or a large section) loads a namespace, all of its children will have automatic access to those translations. A child component only needs to use the composable if it is being used in a different context or needs to load a new namespace not provided by its parent. This avoids redundant calls and keeps your application efficient.
Example: Final Code in Vue.js
Here’s how this pattern looks in a real-world Vue.js application.
Initial Setup (main.ts
and i18n.ts
)
First, you need to set up the global i18n instance. This is done by creating an i18n file and then registering it as a middleware in your main application file.
src/i18n.ts
import { createI18n } from 'vue-i18n';
import commonTranslations from './locales/common.json';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {
common: commonTranslations.en
},
fr: {
common: commonTranslations.fr
},
},
legacy: false,
});
export default i18n;
src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
const app = createApp(App);
app.use(i18n); // Register the i18n instance as a middleware
app.mount('#app');
The Self-Contained Composable (useI18nNamespace.ts
)
This composable handles everything internally, from loading the namespace to managing its ready state.
// src/composables/useI18nNamespace.ts
import { ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import i18n from '@/i18n';
import type { I18nNamespace } from '@/types/namespaces';
const loadingPromises = new Map<I18nNamespace, Promise<void>>();
export function useI18nNamespace(namespace: I18nNamespace) {
const { t, locale } = useI18n({ useScope: 'global' });
const isReady = ref(false);
const load = async (ns: I18nNamespace) => {
isReady.value = false;
const currentLocale = locale.value;
if (i18n.global.getLocaleMessage(currentLocale)?.[ns]) {
isReady.value = true;
return;
}
if (loadingPromises.has(ns)) {
await loadingPromises.get(ns);
isReady.value = true;
return;
}
const promise = (async () => {
try {
const importedMessages = await import(`@/locales/${currentLocale}/${ns}.json`);
i18n.global.mergeLocaleMessage(currentLocale, {
[ns]: importedMessages.default,
});
} catch (error) {
console.error(`Failed to load namespace "${ns}":`, error);
}
})();
loadingPromises.set(ns, promise);
await promise;
isReady.value = true;
};
watchEffect(() => {
load(namespace);
});
return { t, isReady };
}
Namespaces
Your translation files are now organized by language and namespace. A crucial part of this setup is defining a type for your namespaces.
src/types/namespaces.ts
// You must manually update this type with new namespaces
export type I18nNamespace = 'common' | 'home' | 'about' | 'products' | 'cart';
src/locales/common.json
{
"en": {
"submit": "Submit",
"cancel": "Cancel"
},
"fr": {
"submit": "Soumettre",
"cancel": "Annuler"
}
}
src/locales/en/home.json
{
"pageTitle": "Welcome to our Home Page"
}
src/locales/fr/home.json
{
"pageTitle": "Bienvenue sur notre page d'accueil"
}
Component Usage (HomePage.vue
)
The component is simple and declarative. It just uses the isReady
state to manage the UI.
<template>
<div v-if="!isReady">
<p>Loading translations...</p>
</div>
<div v-else>
<h1>{{ t('home.pageTitle') }}</h1>
<button>{{ t('common.submit') }}</button>
</div>
</template>
<script setup lang="ts">
import { useI18nNamespace } from '@/composables/useI18nNamespace';
const { t, isReady } = useI18nNamespace('home');
</script>
Where to Use This Approach
This solution is ideal for any modern web application that is:
- Large and growing: As your application’s features and supported languages increase, this architecture will save you from performance bottlenecks and organizational chaos.
- Performance-critical: E-commerce sites, content management systems, or any application where a fast initial page load is a priority will benefit immensely from lazy-loading.
- Developed by a team: This structure prevents frequent merge conflicts and makes it easier to onboard new developers, as the translation files are logically organized alongside the features they belong to.
This content originally appeared on DEV Community and was authored by Waffeu Rayn