Lazy loading translations in React with code-splitting (2026 guide)

Jakub Pomykała
Jakub Pomykała
Last updated: April 15, 20265 min read
Lazy loading translations in React with code-splitting (2026 guide)

Shipping a 1MB translation bundle for 10 languages when a user only needs one is a straightforward performance regression. Dynamic imports let you fetch only the active locale at runtime, reducing your initial bundle to a fraction of the full size. This technique is one of the practical performance topics covered in our complete technical guide to internationalization and software localization.

Why lazy loading translation files matters

A medium-sized app with around 1,000 unique translation keys produces roughly 50KB of translated messages per language. Supporting 10 languages with a static import strategy puts nearly 1MB of translation data in the initial bundle, regardless of which language the user actually needs.

StrategyInitial bundle (10 languages)Impact
Static imports~1,000 KBSlower LCP, TTI
Lazy loading~100 KBFaster initial load
Namespace splitting~20-50 KB per namespaceBest for large apps

Dynamic imports, supported natively in both Webpack and Vite, solve this cleanly. The bundler creates separate chunks per locale and fetches only the one that matches the current user.

Dynamic import: the core pattern

The underlying mechanism is a standard ES dynamic import wrapped in a function that accepts a locale string.

const loadTranslations = async (locale: string): Promise<Record<string, string>> => {
  try {
    const data = await import(`./locales/${locale}.json`);
    return data.default;
  } catch (error) {
    console.error(`Failed to load translations for locale: ${locale}`, error);
    return {};
  }
};

This works identically in Webpack and Vite projects. The bundler statically analyzes the template literal, finds all JSON files under ./locales/, and splits them into separate chunks automatically.

Using dynamic imports with React state

For apps not using an i18n library, here is a minimal React implementation using useEffect:

import { useState, useEffect } from "react";
 
const [locale, setLocale] = useState('en');
const [messages, setMessages] = useState<Record<string, string>>({});
 
// Detect browser locale on mount
useEffect(() => {
  const browserLocale = window.navigator.language.split('-')[0];
  setLocale(browserLocale);
}, []);
 
// Load translations when locale changes
useEffect(() => {
  const load = async () => {
    const data = await loadTranslations(locale);
    setMessages(data);
  };
  load();
}, [locale]);

Note: useEffect does not support async callbacks directly, so an inner async function or IIFE is required. The window.navigator.language object returns the browser's preferred language, see navigator.language on MDN for details.

For most React projects, using i18next with i18next-resources-to-backend is the cleaner approach. It handles lazy loading, fallback chains, and namespace splitting without manual state management.

Install the required packages:

npm install i18next react-i18next i18next-resources-to-backend

Configure i18next to use dynamic imports as the translation backend:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
 
i18n
  .use(resourcesToBackend((language: string, namespace: string) =>
    import(`./locales/${language}/${namespace}.json`)
  ))
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    defaultNS: 'common',
  });
 
export default i18n;

With this setup, i18next fetches ./locales/de/common.json when a German-speaking user hits your app. Other namespaces (e.g., settings, billing) are fetched only when the corresponding part of the UI is rendered. This is namespace-based splitting: each namespace loads independently.

For more on structuring translation namespaces, see our guide on namespaces in software localization.

Passing loaded messages to FormatJS / IntlProvider

If you are using FormatJS (react-intl) rather than i18next, pass the loaded messages object directly to IntlProvider:

<IntlProvider locale={locale} messages={messages}>
  <App />
</IntlProvider>

For a working example that loads translations from a remote endpoint using the fetch API, see the react-intl SimpleLocalize example on GitHub.

Namespace splitting for large apps

As your translation files grow, splitting by namespace reduces individual chunk sizes further. A common structure:

locales/
  en/
    common.json
    settings.json
    billing.json
  de/
    common.json
    settings.json
    billing.json

With i18next, namespaces are loaded on demand. A user who never visits the billing page never downloads billing.json. This mirrors route-based code splitting for JavaScript, the same principle applied to translation resources.

Working with SimpleLocalize CLI

If your translation files live in SimpleLocalize, the SimpleLocalize CLI can download them into the structure your dynamic imports expect. A typical download command in your CI or local workflow:

simplelocalize download --downloadPath ./src/{lang}/{ns}/messages.json

This outputs per-locale, per-namespace JSON files that match the import path used in the i18next configuration above. See the SimpleLocalize CLI docs for full configuration options.

Does dynamic import work in Vite?
Yes. Vite supports dynamic import() natively and handles the locale template literal pattern the same way Webpack does. No additional configuration is required.
What happens when a translation file fails to load?
The try/catch in the loadTranslations function returns an empty object, and i18next will fall back to fallbackLng. In production, log the error to your monitoring system so missing locales are caught early.
Should I lazy load or use a CDN for translations?
For large apps or frequently updated translations, serving files from a CDN removes the need for rebuilds on every translation change. Translation hosting decouples translation updates from code deployments entirely. Lazy loading from the bundle is simpler and works well for smaller projects or infrequently changed content.
How do I split translation files by route?
Load namespaces that correspond to routes inside getStaticProps or getServerSideProps in Next.js, or inside route loaders in React Router v6. Request only the namespaces each route needs, then pass them as initial state to i18next.
Jakub Pomykała
Jakub Pomykała
Founder of SimpleLocalize

Get started with SimpleLocalize

  • All-in-one localization platform
  • Web-based translation editor for your team
  • Auto-translation, QA-checks, AI and more
  • See how easily you can start localizing your product.
  • Powerful API, hosting, integrations and developer tools
  • Unmatched customer support
Start for free
No credit card required5-minute setup
"The product
and support
are fantastic."
Laars Buur|CTO
"The support is
blazing fast,
thank you Jakub!"
Stefan|Developer
"Interface that
makes any dev
feel at home!"
Dario De Cianni|CTO
"Excellent app,
saves my time
and money"
Dmitry Melnik|Developer