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.
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.
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.
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.
Related
- React i18next Component Patterns — the component-level idioms this extends to a server boundary.
- Next.js i18n Routing Setup — where the locale param comes from.
- String Catalog Governance — the namespace boundaries that decide what crosses.
- react-i18next Suspense flicker on namespace load — the client-side loading problem that follows.
Part of React i18next Component Patterns.