Join early access

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 figure lifting the lid off a box and looking inside, drawn in white line on a slate ground

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.

Nothing here is broken. Every string renders, every locale works, the tests pass, and the change that introduced it was four lines that read as an obvious cleanup. The only symptom is a number in a build log, and build logs are not reviewed.

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.

CrossingWhat reaches the browserWhere it is visible
Static import inside a client moduleThe whole default export, in a client chunkBundle analyzer, first-load JS
Prop passed to a client componentA serialized copy in the flight payloadThe HTML response only; no bundle report shows it
Provider mounted in the root layoutBoth, on every route beneath itThe analyzer and every HTML response
Two channels with one symptom. Measuring the wrong one is how a team spends a week shaking a bundle that was never the problem.

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.

tsapp/[lang]/layout.tsx
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}
Every route in the tree now carries every namespace. next-intl's own documentation recommends being selective and demonstrates picking a single namespace, with the honest caveat that the picking depends on knowing what the wrapped components use, and that nothing checks it for you.

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

tslib/i18n.ts
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}
A helpers module that grew a convenience export.
tscomponents/PriceTag.tsx
01"use client";0203import { formatPrice } from "@/lib/i18n";0405export function PriceTag({ cents, locale }) {06  return <span>{formatPrice(cents, locale)}</span>;07}
One pure function is imported. A module is what crosses the boundary, not an export.

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

Tracing the client graph
npx next experimental-analyze
Also useful
  • --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.

bash
# 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 -c
If the grep is not zero, the catalog crossed, and it does not matter yet which of the three shapes did it. The two byte counts are the crude version: if the same route differs between locales by roughly the difference between two catalog files, the whole catalog is in the response.

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

tsapp/[lang]/dictionaries.ts
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]();
Two things in this file are load-bearing, and neither is the lookup.

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.

tscomponents/AddToCart.tsx
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}
Two labels, and a dependency on a provider that has to be mounted above this component with enough of the catalog in it to answer both keys.
tscomponents/AddToCart.tsx
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}
The same component, given the two strings it renders. No provider above it, no resolver in the bundle, and the cost of a label is now a line in a type.
tsapp/[lang]/products/[sku]/page.tsx
01const dict = await getDictionary(lang);0203return (04  <AddToCart05    sku={sku}06    labels={{ idle: dict.products.cart, pending: dict.products.adding }}07  />08);
The Server Component that renders it does the lookup, so what crosses the boundary is two strings rather than a catalog and a resolver.

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.

The barrel, after the gate
npm run build
Error
  • 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.

text
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 budget
The settings route is carrying something the other two are not. Before the budget existed, this was a fact about production that nobody in the company knew.

The 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

  1. Next.js documentation Server and Client Components nextjs.org, App Router
  2. Next.js documentation Internationalization nextjs.org, App Router guides
  3. next-intl documentation Server & Client Components next-intl.dev
  4. Next.js documentation Optimizing package bundling nextjs.org, App Router guides