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