Cutting 26.8 KB by precompiling i18n messages

Performancei18n· 3 min read

Adding next-intl (see the previous post) solved locale routing. It also added a dependency on @formatjs's ICU message parser, which by default runs in the browser — every visitor's client downloads and executes the machinery that parses {minutes, plural, one {...} other {...}}-style message syntax, even though every real message this site ships is known at build time and never changes per-request.

Measuring before assuming

Rather than guessing at the cost, I used Next.js 16.1+'s built-in, Turbopack-native bundle analyzer (npx next experimental-analyze) to get a real module treemap. It surfaced a dedicated 22.88 KB top-level @formatjs/ block sitting in the client bundle — not a rounding error, and not something the Lighthouse bootup-time/mainthread-work-breakdown audits (both flagged at score 0 on every run of this site so far) were going to explain on their own without opening the treemap directly.

Note

I'd initially reached for @next/bundle-analyzer out of habit, since it's the more commonly referenced tool. The build immediately reported it isn't compatible with Turbopack builds — a real, documented limitation, not a misconfiguration on my end. Removed it and switched to Next's own built-in analyzer instead of working around the incompatibility.

The fix

next-intl ships an experimental.messages.precompile option that moves ICU parsing to build time instead of the client. The message dictionaries get compiled into a format the runtime can consume directly, with no parser needed in the browser at all.

const withNextIntl = createNextIntlPlugin({
  requestConfig: "./i18n/request.ts",
  experimental: {
    messages: {
      path: "./messages",
      format: "json",
      locales: "infer",
      precompile: true,
    },
  },
});

Turning it on immediately hit a real TypeScript error — the installed version's type for experimental.messages requires a locales field that the library's own minimal documentation example for this specific option omits. Adding locales: "infer" (auto-detect from the messages/ directory) fixed it.

Verifying the actual gain

Decision

I didn't take the feature's existence as proof it worked — I re-ran the bundle analyzer before and after and diffed the real numbers, the same discipline as the Lighthouse investigation in the post above.

"All Route Modules" went from 386.56 KB to 359.76 KB compressed — a 26.8 KB reduction, roughly matching the @formatjs/ block identified earlier (which is now gone from the treemap entirely, aside from a residual sliver). Module count dropped from 227 to 224. npm run build and npm test both stayed clean afterward — 24/24 pages, full test suite passing.

What I'd do differently

Nothing about the sequence — measure, form a hypothesis about the cause, apply a targeted fix, measure again to confirm the actual gain rather than trusting the feature description. The one thing I'd change going forward: check a config option's full type surface against the installed version before writing the minimal example from the docs, since this is the second time in the same day that gap caught me (see the previous post's hasLocale incident).