React Server Components: passing messages to the client

The application renders correctly and the JavaScript payload has grown by a hundred kilobytes. Somewhere a server component passed the message catalogue to a client component as a prop, and every string for the locale — including the ones only the server renders — is now serialised into the response and downloaded by the browser.

The server-client boundary is a serialisation boundary. Anything crossing it is data in the response, and a catalogue is a large piece of data.

What may cross the server-client boundary A full catalogue can live in a server component and must never be handed to a client component, because everything passed across the boundary is serialised into the response. A single namespace may cross when the page genuinely renders it. The resolved locale is a cheap string. A formatter instance cannot cross at all and must be rebuilt on the client. Where each piece of the catalogue can live Server component Client component Full catalogue yes — never serialised no — would ship to the browser One namespace yes yes, if the page renders it The resolved locale yes yes — a string, cheap A formatter instance yes rebuild it there — not serialisable
Everything passed to a client component is downloaded. That single fact decides the whole design.

Root cause: props are payload

A server component runs only on the server: it can read files, query databases, and hold a whole message catalogue at no cost to the reader, because none of it is sent. A client component runs in both places, so its props must be serialised into the HTML response for hydration.

That distinction is invisible in the code. Passing a messages object to a client component looks identical to passing a title string, and does something completely different: the object is JSON-encoded into the response and parsed by the browser.

Two related mistakes follow from the same misunderstanding. A provider wrapping the tree — the familiar pattern from client-side React — is a client component, so putting the catalogue in it sends the catalogue. And a function, such as a t helper or an Intl formatter, cannot cross the boundary at all: functions are not serialisable, and the failure is a runtime error rather than a payload regression.

Translation across the server-client split The route resolves the locale from the path, the server renders text using a catalogue that never leaves it, the client receives rendered strings plus at most one small namespace, and interactive re-renders draw on that small client catalogue. Where translation happens Route resolves locale from the path segment load Server renders text catalogue stays on the server pass Client gets rendered strings plus one namespace, if needed hydrate Interaction re-renders from the small client catalogue Text a reader only reads is translated on the server. Text that changes on interaction needs a catalogue in the browser.
The question for every string is whether it can change without a server round trip.

Minimal reproducible example

// app/[locale]/page.tsx — a server component
import messages from '@/locales/de.json';        // fine here: never leaves the server
import { InteractiveFilter } from './filter';    // a client component

export default function Page() {
  // Every string in the locale is now serialised into the response.
  return <InteractiveFilter messages={messages} />;
}

The fix: translate on the server, pass strings, scope what must cross

// app/[locale]/page.tsx — server component
import { getTranslations } from '@/i18n/server';
import { InteractiveFilter } from './filter';

export default async function Page({ params }: { params: { locale: string } }) {
  const t = await getTranslations(params.locale, 'catalogue');

  return (
    <section>
      {/* Static text: rendered here, costs the client nothing */}
      <h1>{t('catalogue.title')}</h1>
      <p>{t('catalogue.intro')}</p>

      {/* Interactive subtree: give it only the strings it can render */}
      <InteractiveFilter
        locale={params.locale}
        labels={{
          apply: t('catalogue.filter.apply'),
          clear: t('catalogue.filter.clear'),
          empty: t('catalogue.filter.empty'),
        }}
      />
    </section>
  );
}

The interactive component receives three strings rather than a catalogue. The locale crosses as a string, which is cheap and lets the client rebuild anything locale-dependent it needs.

Five rules for i18n across a server-client boundary The locale comes from the route parameter rather than a context that would need to cross the boundary. Anything that does not change on interaction is translated on the server. Client components receive already-rendered strings rather than a catalogue. Trees that genuinely re-render on the client get the smallest namespace covering them. And formatter instances are constructed on the client rather than passed. Splitting the catalogue by boundary 1 Resolve the locale in the route segment it is a param, not a context 2 Translate on the server wherever possible static text needs no client catalogue 3 Pass rendered strings, not the catalogue props are serialised and downloaded 4 Give interactive trees their own namespace the smallest one that covers them 5 Rebuild formatters on the client functions and instances cannot cross
Rule three is the one with a measurable payload consequence; the rest follow from it.

When a client component genuinely needs a catalogue

Passing individual labels works when the set of strings a component can render is known in advance. It stops working when the component chooses strings dynamically — a validation message keyed by an error code, a status label keyed by a server response, a list rendered from data.

In those cases the component needs a catalogue, and the goal shifts to making it the smallest one that covers the component’s needs. That is what namespaces are for, and it is where the boundaries described in string catalog governance pay off directly: a component needing the filters namespace ships the filters namespace, not the product’s entire copy.

// The provider is a client component; give it one namespace, not everything.
import { NextIntlClientProvider } from 'next-intl';

const messages = await getMessages(locale, ['filters']);

return (
  <NextIntlClientProvider locale={locale} messages={messages}>
    <InteractiveFilter />
  </NextIntlClientProvider>
);

The rule of thumb that keeps this honest: if a string can only change as a result of a server round trip, it should be rendered on the server. If it can change in response to a click, it needs to be available on the client.

Formatters cannot cross, and that is fine

An Intl.NumberFormat instance is not serialisable, so it cannot be a prop. That sounds like a limitation and is mostly a clarification: a formatter is cheap to construct from a locale string, and the locale string crosses freely.

The pattern is to pass the locale and build the formatter where it is used, with the same caching argument that applies everywhere else — construct once per locale in a module-level map rather than per render, as described in date and number formatting standards.

Where a formatted value never changes after render, format it on the server and pass the string. That is strictly cheaper: no formatter is constructed in the browser, and no locale data is needed there at all.

Verification

test('no catalogue is serialised into the response', async () => {
  const html = await fetch('/de/catalogue').then((r) => r.text());
  // A string that only the server renders must not appear in the RSC payload.
  expect(html).not.toContain('catalogue.legal.disclaimer');
  expect(html).toContain('Rechtlicher Hinweis');   // rendered, not shipped as a key
});

test('the client bundle carries at most one namespace', async () => {
  const payload = await getRscPayloadFor('/de/catalogue');
  const keys = extractMessageKeys(payload);
  expect(new Set(keys.map((k) => k.split('.')[0]))).toEqual(new Set(['filters']));
});

The first assertion is the durable one: a key appearing in the response means the catalogue crossed the boundary, and a rendered string means it did not.

Measuring what actually crossed

The payload cost of this is easy to reason about and easy to get wrong by accident, so it is worth measuring rather than assuming — and the measurement is simpler than it sounds.

The serialised payload is in the response. Fetching a route and searching the body for message keys answers the question directly: a key present means a catalogue entry was serialised, and a rendered translation means it was not. That single grep, run over a handful of representative routes, is the whole check.

Two refinements make it useful as a regression test. Record a size rather than only a pass or fail, so a gradual increase is visible before it becomes a problem; a route whose serialised i18n payload grows from two kilobytes to twenty over a quarter is a boundary that has been eroding. And assert the namespace set rather than the byte count where you can, because that is the property you actually care about and it does not drift with copy changes.

There is a third measurement worth taking once: the difference between a page rendered entirely on the server and the same page with its interactive parts. That difference is the true cost of interactivity for that route, and seeing it in kilobytes tends to settle design arguments about whether a component needs to be interactive at all.

None of this needs sophisticated tooling. The response body is text, the keys are distinctive, and the check fits in a few lines of an end-to-end test — which is precisely why it is worth having rather than relying on review to catch a prop that should not have crossed.

When to escalate

If the payload is large and no catalogue is being passed, look for a provider higher in the tree — a layout wrapping everything in a client provider sends its messages on every route.

If a component throws about non-serialisable props, a function or a class instance is crossing. The message names the prop, and the fix is to pass the data it was built from instead.

If translations are correct on first load and wrong after a client navigation, the client catalogue is missing the namespace that route needs, which is the loading-order problem described in react-i18next Suspense flicker on namespace load.

FAQ

Should the locale come from a context or from the route?

From the route. It is a path segment, so every server component can read it from its params without a context, and a context would have to be a client component — which puts a provider in the tree for a value that is already available.

Does this apply to other server-rendering frameworks?

The specific boundary is a React one, and the principle generalises: anything a framework serialises into the response for hydration is payload. Nuxt’s payload and SvelteKit’s load data have the same property, so the same question — can this be rendered rather than shipped — applies to both.

Is it worth splitting a catalogue further for this?

Often yes. The boundary makes namespace granularity visible in a way client-side rendering does not, because the cost of an over-broad namespace is now a measurable number of kilobytes rather than a slightly larger bundle.

What about strings for error states that may never render?

Those are the classic case for passing a small labelled set rather than a catalogue: an error component can receive its three or four messages as props even though only one will ever be shown, and three strings is a negligible payload.

Part of React i18next Component Patterns.