In Next.js, the Props You Pass to a Client Component Ship to Every Visitor
Why data passed from a Server Component to a Client Component ends up in your page's HTML, how it leaked draft content on this site, and how to measure and trim it.
The App Router makes it easy to forget where the line between server and browser sits. You fetch data in a Server Component, pass it to a Client Component as a prop, and everything just works.
What is easy to miss is that "passing it as a prop" means serialising it into the page. Every prop a Client Component receives has to reach the browser, so that React can hydrate the component there. If that component sits in a layout, its props are on every page that layout wraps.
I found this out on the site you are reading now. Here is what happened, how to check your own app, and the rules I follow now.
What the RSC payload is
When Next.js renders an App Router page, the HTML is only half of what it sends. The other half is the React Server Component payload: a serialised description of the component tree that React uses to hydrate the page and to navigate client-side without a full reload.
For the first page load, that payload is inlined into the HTML in script tags that push into self.__next_f. Server Components contribute their rendered output to it. Client Components contribute their props — all of them, exactly as passed, because the browser has to run those components itself.
So the cost of a Client Component is not just its JavaScript bundle. It is also every byte of data you hand it.
How this site got it wrong
This portfolio is driven by a small CMS. Every page reads one config object containing the site's copy, the list of projects, the blog posts and so on. Several Client Components — the navigation, the hero, the projects and contact sections — need pieces of it, so the root layout wrapped the site in a context provider:
// app/(site)/layout.tsx — the version that leaked
const config = await getAllConfig();
return (
<SiteConfigProvider config={config}>
{children}
</SiteConfigProvider>
);SiteConfigProvider is a Client Component, so config — the whole thing — was serialised into every page. That meant:
- Every blog post's full Markdown travelled on every route, about 28KB of it, on pages that never render a single article.
- Every project's full case study travelled too: long descriptions, screenshot lists, the lot. On
/contact, the projects data came to roughly 170KB, about 103KB of which was case-study prose that no component on that page displays. - Unpublished drafts were public. Projects marked
published: falsewere filtered out when rendering, so they never appeared on screen. They were still in the data, so the client name and case study of an unfinished project sat in the page source of every route. Anyone who pressed View Source could read them.
That last one is the real lesson. Filtering at render time decides what people see. It does not decide what gets sent.
How to check your own app
You do not need any tooling. Pick a string that should only exist on one page — a sentence from the middle of a blog post, or the title of something unpublished — then open a different page and search View Source for it. If it is there, it is shipping.
To see how big the payload is, run this in the browser console on any App Router page:
// Approximate size of the inlined RSC payload, in characters
self.__next_f.map((chunk) => chunk[1] ?? "").join("").lengthCompare that number across routes. If a simple page like /contact carries a payload close to your heaviest page, something in a layout is over-sharing.
The fix
The fix is to decide what the client actually needs, and to make that the only thing that crosses the boundary.
// app/(site)/layout.tsx — what ships now
const config = await getAllConfig();
// Server-only sections never cross the boundary at all.
const { pages, hire, blog, projects, ...rest } = config;
const clientConfig = {
...rest,
projects: {
...projects,
projects: projects.projects
// Drafts are removed from the *data*, not hidden in the UI.
.filter((p) => p.published !== false)
// A card needs a title, a slug and a thumbnail — not a case study.
.map(toProjectCard),
},
};
return (
<SiteConfigProvider config={clientConfig}>
{children}
</SiteConfigProvider>
);The pages that need the full data — a blog post, a project's case study — are Server Components, and they read it themselves on the server. Their output reaches the browser as rendered HTML, not as raw data.
Isn't Brotli compressing all this?
Yes, and that is why this kind of problem survives for months. Text compresses extremely well, so the transfer size barely moves and nothing in a Lighthouse score jumps out.
But compression only helps on the wire. The browser still has to decompress the payload, parse it, and hold it in memory while it hydrates the page. On a mid-range phone that is real work for data the page cannot use. And compression does nothing for the privacy problem: compressed or not, the draft is in the page.
The rules I follow now
Pass the minimum. A Client Component's props are a public API to the browser. Pass IDs, labels and display-ready strings, not database rows or whole config objects.
Shape data on the server. Write a small function like toProjectCard that maps the full record to exactly what the component renders. It also documents what the component depends on.
Filter by visibility in the data layer. Anything unpublished, private or admin-only should be removed before it gets near a Client Component, not hidden with a condition in JSX.
Be suspicious of providers in layouts. A context provider at the root is the most efficient way to put data on every page. Sometimes that is what you want. Often it is not.
Mark server-only modules. Importing the server-only package at the top of a data module makes the build fail if a Client Component ever imports it directly. Next.js also has an experimental taint option, built on React's taint APIs, that throws if a specific object or value is passed to the client. Both turn a silent leak into a loud error.
The App Router's model is genuinely good: render on the server, send the browser only what it needs. It just will not stop you sending it more.