Author:
    Creation:2026-09-02Last update:2026-09-02

    Vite i18n: the parts that are about Vite, not your framework

    Most "Vite i18n" tutorials are really React or Vue tutorials that happen to use Vite. This one is about the layer underneath: how catalogs get imported, what Rollup does with them, and why the lazy loading you wrote is probably not lazy.

    Table of Contents

    Static import is the default, and it is eager

    The simplest setup imports every catalog at the top of a module.

    src/i18n.ts
    import en from "./locales/en.json";
    import fr from "./locales/fr.json";
    import ja from "./locales/ja.json";
    

    That is three catalogs in the entry chunk, on every page, for every user. It is fine for two locales and a hundred strings. At ten locales it is the single biggest avoidable cost in the bundle.

    import.meta.glob and the flag everyone gets wrong

    Vite's glob import is the usual fix.

    ts
    const catalogs = import.meta.glob("./locales/*.json");
    
    export const loadCatalog = async (locale: string) => {
      const load = catalogs[`./locales/${locale}.json`];
      return (await load()) as Record<string, string>;
    };
    

    Lazy is the default: each entry is a function returning a dynamic import, and Rollup emits one chunk per file. Adding { eager: true } inlines all of them into the importing module, which is exactly what you were trying to avoid.

    ts
    // Every locale, in the entry chunk. Almost never what you want.
    const catalogs = import.meta.glob("./locales/*.json", { eager: true });
    

    The trap is that both versions work in dev, because Vite serves modules unbundled. The difference only shows up in dist. Check with npx vite build && npx vite preview, then look at what the entry chunk actually contains.

    Per-route splitting rarely splits

    This is the part that surprises people. You split catalogs by page:

    plaintext
    locales/en/home.json
    locales/en/checkout.json
    

    Then two routes both import checkout.json, and Rollup hoists it into a shared chunk that loads on both. Rollup's chunking is driven by the module graph, not by your folder names: a module reachable from more than one entry becomes common. Adding a third route that imports it changes nothing, and adding a fourth might split it differently.

    So per-route locale splitting only holds if the import graph really is disjoint. If it matters, verify it rather than assume it:

    bash
    npx vite build && npx vite-bundle-visualizer
    

    If you need to force the boundary, build.rollupOptions.output.manualChunks is the escape hatch, at the cost of maintaining it by hand.

    Catalogs do not hot-reload

    Edit a component, Vite swaps it in. Edit locales/fr.json, and depending on how it was imported, nothing happens. JSON imported dynamically has no HMR boundary, so the module graph does not know to invalidate the consumers.

    People work around this by restarting the dev server every time they touch a string, usually without realising it is avoidable. The fix belongs to the i18n plugin: it has to accept the HMR update and push new messages into the running app. When you evaluate a library, check whether its Vite plugin does this, because it is a daily-friction thing rather than a production thing.

    define bakes the locale in

    It is tempting to resolve the default locale at build time:

    vite.config.ts
    export default defineConfig({
      define: {
        __DEFAULT_LOCALE__: JSON.stringify(process.env.LOCALE ?? "en"),
      },
    });
    

    define is a textual replacement performed at build time. Whatever value is present when you build is the value that ships, so this forces one build per locale. That is a legitimate strategy, and it is exactly what Angular's official i18n does. It is not what you want if a single deployment has to serve every language.

    For values that must vary per request, keep them out of define and resolve them at runtime.

    Moving message parsing to build time

    Every mature option in this ecosystem eventually does the same thing: stop parsing messages in the browser.

    Plugin What it moves to build time
    @intlify/unplugin-vue-i18n Compiles vue-i18n messages to render functions, lets you ship the runtime-only build
    Lingui (macro plus plugin) Extracts and compiles catalogs, replaces macros with message IDs
    Paraglide (inlang) Compiles each message into its own tree-shakable function
    vite-intlayer Builds per-component dictionaries, then purges and minifies unused fields

    The shared payoff is twofold: the runtime message compiler stops shipping, and unused entries become statically removable. The shared cost is that your dev server and your CI both need the plugin, and a bare tsc or a non-Vite test runner will not see the generated output without extra configuration.

    vue-i18n is the clearest example of the first payoff. Without @intlify/unplugin-vue-i18n you ship a compiler that calls new Function, which is both bytes you did not need and a Content Security Policy problem.

    SSR: never hold the locale in a module

    If you add SSR, whether through a framework or vite-plugin-ssr, the rule that matters is this: a module-level variable holding the current locale is shared across every concurrent request on that server process.

    ts
    // Fine in a browser. A cross-request data leak on a server.
    export let currentLocale = "en";
    

    Two users hitting the server at the same time will race, and one of them gets the other's language. It does not reproduce in development because you are the only request. Resolve the locale per request and pass it down explicitly, through context or through your framework's request-local storage.

    Intlayer's Vite plugin

    Intlayer registers a single plugin that handles the dictionary build, dev-mode watching and the optimization pipeline.

    vite.config.ts
    import react from "@vitejs/plugin-react";
    import { defineConfig } from "vite";
    import { intlayer } from "vite-intlayer";
    
    export default defineConfig({
      plugins: [react(), intlayer()],
    });
    

    Import rewriting, purge and minify are on by default. The two flags worth knowing live in intlayer.config.ts:

    intlayer.config.ts
    import type { IntlayerConfig } from "intlayer";
    
    const config: IntlayerConfig = {
      build: {
        purge: true, // drop content fields no component reads
        minify: true, // rename content keys to short aliases
      },
    };
    
    export default config;
    

    Because content is declared per component rather than per locale file, the purge pass has a module graph to work against, which is what makes the removal safe. The trade is the one named above: the plugin is mandatory everywhere the code is compiled, including CI and test runners. Details in bundle optimization.

    Common mistakes

    • { eager: true } on a glob you meant to lazy-load. Works in dev, ships everything in prod.
    • Trusting folder structure to produce chunks. Rollup follows imports, not directories. Measure the build.
    • Restarting the dev server to see a translation change. That is a missing HMR handler, not normal.
    • Putting the locale in define. You have just committed to one build per language.
    • Module-level locale state with SSR. Cross-request leak that never appears locally.
    • Benchmarking the dev server. Unbundled modules tell you nothing about the bundle.

    Going further

    Comments

    No comments yet. Be the first to share your thoughts.

    Related Posts

    Last Posts