The catalog on the wrong side of the boundary: how a Next.js app ships every string to every visitor
A message catalog that crosses the client boundary is downloaded in full by everyone, on every route, in languages they will never see. The page still works, review still passes, and the only symptom is a number in a build log nobody reads.

A page that got heavier without being touched
An App Router app, twelve locales, a message catalog that started at two hundred keys and is now at four thousand. Nobody has touched the pricing page in months. Its first-load JavaScript has grown four times in the same period.
Then the Japanese launch ships. A new locale, a new file, no change to any page, and the number moves again. That is the tell. The payload is tracking the size of the catalog rather than the size of the page, and the pricing page renders about forty strings.
Somewhere between the file and the browser, the catalog stopped being forty strings and became a file. Once it is a file, everyone gets all of it: every namespace, on every route, and in the worst arrangement, every language, including the eleven a given visitor will never see.
What the directive actually marks
The usual mental model of use client is “this component runs in the browser”. That is true, and it is not the part that costs anything. The directive marks a boundary between two module graphs, and the documentation is blunt about which side things land on: once a file is marked, all of its imports and the components it directly renders are included in the client bundle1.
Imports, transitively. A JSON catalog imported four modules below the component that carries the directive is in the browser’s download. And a JSON import is one default export, which is one object. A bundler cannot prove which keys you read, so it takes the object.
There is a second way across, and it surprises people more, because it involves importing nothing at all. The RSC payload carries “any props passed from a Server Component to a Client Component”1. A provider is a client component. Handing it the catalog serializes the catalog into the response.
That one is worth sitting with. It is not in the JavaScript bundle. It is in the HTML, as flight data, on every request, and it is not cached the way a hashed chunk is. It also does not appear in any bundle report, for the simple reason that it is not in a bundle.
| Crossing | What reaches the browser | Where it is visible |
|---|---|---|
| Static import inside a client module | The whole default export, in a client chunk | Bundle analyzer, first-load JS |
| Prop passed to a client component | A serialized copy in the flight payload | The HTML response only; no bundle report shows it |
| Provider mounted in the root layout | Both, on every route beneath it | The analyzer and every HTML response |
Three ways a catalog crosses
Three shapes, in roughly the order they get written.
The provider at the root. It is the arrangement every quickstart shows, and it works perfectly. It is also total: the root layout does not know which route it is wrapping, so it wraps all of them with all of the namespaces.
01import { NextIntlClientProvider } from "next-intl";02import { getMessages } from "next-intl/server";0304export default async function LangLayout({ children }) {05 const messages = await getMessages();0607 return (08 <NextIntlClientProvider messages={messages}>09 {children}10 </NextIntlClientProvider>11 );12}The caveat is the interesting half3. Selecting a subset is correct and it is a judgement call made by hand, against the current implementation of components somebody else may edit next week.
The convenience hook in a leaf. A component needs one label, so it reaches for the translation hook. The hook needs the provider. The provider gets hoisted to the root, because that is the one place guaranteed to be above everything. The catalog is not the cause here; the hoisting is, and a single label forced it.
The barrel. This is the one that survives review, because the file that causes it does not contain the word “messages”.
01import de from "@/messages/de.json";02import en from "@/messages/en.json";03import ja from "@/messages/ja.json";0405export const dictionaries = { de, en, ja };0607export function formatPrice(cents: number, locale: string) {08 return new Intl.NumberFormat(locale, {09 style: "currency",10 currency: "EUR",11 }).format(cents / 100);12}01"use client";0203import { formatPrice } from "@/lib/i18n";0405export function PriceTag({ cents, locale }) {06 return <span>{formatPrice(cents, locale)}</span>;07}Tree shaking is supposed to save you here, and sometimes it does. Whether it does depends on whether the bundler can prove the rest of the module is side-effect free, on how the package declares sideEffects, on whether anything in the graph reads the object dynamically, and on which bundler ran. That is four conditions, none of them visible in a diff, all of them decided by someone else’s configuration. The reliable version of this is not to have the barrel.
Measuring it before fixing it
Two channels, two measurements, and it is worth doing both before changing anything: the fixes are different and the symptom is identical.
For the JavaScript channel the framework ships an analyzer. It traces the module graph, so it answers the question that actually matters, which is not “what is big” but “what dragged this in”.
- --output writes to .next/diagnostics/analyze instead of opening a browser,
- so two builds can be copied aside and diffed.
- On webpack, the equivalent is @next/bundle-analyzer with ANALYZE=true.
For the flight channel nothing in the toolchain will tell you, so ask the response instead. Pick a string that only one namespace has, then fetch a page that has no business rendering it4.
# channel one: the client JavaScriptnpx next experimental-analyze# channel two: the flight payload. pick a string that only the settings# namespace has, then ask for a page that does not render settings.curl -s http://localhost:3000/de/pricing | grep -c 'Rechnungsverlauf'# and the blunt version, run against the same route in two localescurl -s http://localhost:3000/en/pricing | wc -ccurl -s http://localhost:3000/de/pricing | wc -cFix one: the text never leaves the server
The default position is that strings are read on the server and only the resulting markup goes over the wire. The framework’s own internationalization guide states the consequence plainly: because layouts and pages default to Server Components, the size of the translation files does not affect the client bundle, the code runs only on the server, and only the resulting HTML reaches the browser2.
01import "server-only";0203const dictionaries = {04 en: () => import("./dictionaries/en.json").then((m) => m.default),05 de: () => import("./dictionaries/de.json").then((m) => m.default),06 ja: () => import("./dictionaries/ja.json").then((m) => m.default),07};0809export type Locale = keyof typeof dictionaries;1011export const hasLocale = (locale: string): locale is Locale =>12 locale in dictionaries;1314export const getDictionary = async (locale: Locale) => dictionaries[locale]();The dynamic import() per locale is what keeps twelve locales from being one chunk; a static namespace import would pull all twelve into the build and load them together, on the server, for a request that needs one. The server-only import is the gate, and it gets its own section below.
Current versions add next/root-params, which lets a server-side utility read the locale segment without prop drilling lang through every layer. It has the same property worth wanting here: a file that imports it fails at build time if it ends up in a Client Component2.
Fix two: pass strings, not dictionaries
Not everything can be a Server Component. A control with state and event handlers has to run in the browser, and plenty of those have text in them. The rule is not about where a string lives. It is about what crosses.
01"use client";0203import { useTranslations } from "next-intl";0405export function AddToCart({ sku }) {06 const t = useTranslations();07 const [pending, setPending] = useState(false);0809 return (10 <button onClick={() => add(sku, setPending)}>11 {pending ? t("products.adding") : t("products.cart")}12 </button>13 );14}01"use client";0203export function AddToCart({ sku, labels }: {04 sku: string;05 labels: { idle: string; pending: string };06}) {07 const [pending, setPending] = useState(false);0809 return (10 <button onClick={() => add(sku, setPending)}>11 {pending ? labels.pending : labels.idle}12 </button>13 );14}01const dict = await getDictionary(lang);0203return (04 <AddToCart05 sku={sku}06 labels={{ idle: dict.products.cart, pending: dict.products.adding }}07 />08);The second version has a property the first one does not: the cost is visible in a type. Eleven strings is eleven lines, and somebody asks why a button needs eleven strings. A messages prop hides an unbounded number behind one word, and it costs exactly the same to write whether the catalog holds two hundred entries or four thousand.
There is also a composition move that people forget. Server Components passed to a Client Component as children are not in the client’s module graph. They render on the server and arrive as rendered output1. A client <Modal> can hold a server-rendered, fully localized body, and the modal itself never sees a string.
Fix three: make the boundary a build error
Everything above is a convention, and a convention is a request. Requests have a failure rate. The version of this that survives a busy week is the one the compiler enforces, and it is a single line.
import "server-only" at the top of the dictionary module makes importing that module from anywhere in a client graph a build error rather than a payload1. It catches the barrel and the hoisted provider in the same pass, because both of them are, in the end, an import.
- x You are importing a component that needs "server-only".
- That only works in a Server Component but one of its parents is
- marked with "use client", so it is a Client Component.
- Import trace:
- ./app/[lang]/dictionaries.ts
- ./lib/i18n.ts
- ./components/PriceTag.tsx
The import trace is the part that pays for itself. The failure names the barrel, which is the module nobody would have suspected, and it does it in the seconds after the change rather than in a performance review three months later.
An ESLint no-restricted-imports rule pointed at @/messages/* is worth adding beside it. It is weaker, since it matches on paths rather than on graphs, and it catches the careless version earlier and with a better message.
server-only guards the module graph and nothing else. A Server Component may legitimately import the dictionary and then hand the entire object to a Client Component as a prop, and the gate will not object, because at the module level nothing is wrong. That path belongs to the previous section, and it is the reason the payload measurement has to live in CI rather than in a checklist.Fix four: a number CI can fail on
This class of bug comes back because nothing fails when it does. It arrived in a four-line change and it will return in another one, on a Thursday, in a pull request about something else.
So give it a number. After the build, assert two things for a handful of representative routes: the first-load JavaScript, and the byte size of the flight payload for one rendered page, in one fixed locale so the figure is comparable between builds.
route first load JS flight payload/[lang] 98.4 kB 17.9 kB/[lang]/pricing 101.2 kB 18.6 kB/[lang]/settings 103.8 kB 41.6 kB over budget: 30 kBFAIL 1 route over its flight budgetThe threshold is not the interesting part. Pick something slightly above today’s figure and raise it deliberately, in a commit that says why. What matters is that a regression stops a merge, in the one moment when the person who caused it still has the context to fix it in five minutes.
The catalog is only the example
None of this is really about localization. The catalog is simply the largest piece of pure data most applications carry, so it is where the boundary gets tested first.
The same shape moves other cargo. A timezone table. An icon barrel that ships a thousand components for the six a page renders. A markdown parser. A validation schema shared with the server. The framework’s bundling guide uses a syntax highlighter as its example: a Client Component renders a static block of code, and the entire tokenizer is shipped to do it, when moving the same work to the server sends markup instead4.
The question that separates these cases is not whether a component is interactive. It is whether the component needs to do something in the browser, or needs to have something at render time. The directive is about doing. Data that only had to be read once, at render, has no reason to cross at all.
Localization exposes it first for a reason worth stating plainly: the catalog is the one asset that grows with a business decision rather than with a code change. Sales signs a contract in Japan, and a page in Ohio gets heavier. If that sentence describes your application, the problem was never the size of the catalog. It is that nothing in the build knew the boundary was there.
References
- Next.js documentation Server and Client Components nextjs.org, App Router
- Next.js documentation Internationalization nextjs.org, App Router guides
- next-intl documentation Server & Client Components next-intl.dev
- Next.js documentation Optimizing package bundling nextjs.org, App Router guides