React Server Components and Client Bundle Size

This guide belongs to Hydration & Partial Rehydration Strategies in JavaScript Bundle Optimization & Code Splitting. React Server Components (RSC), the default in the Next.js App Router and supported by other frameworks, render on the server and send the browser a serialised description of their output — not their code. A server component that formats dates with a heavy library, renders markdown with a parser, or queries a database ships none of that code to the client. Only components marked with "use client" (and everything they import) become client JavaScript and hydrate.

That makes RSC one of the most effective bundle-reduction tools available to React applications — when the client boundary is placed well. Placed badly, a single "use client" near the top of the tree turns most of the page back into client code, and the app ships as much JavaScript as before, plus the RSC payload.

Where the "use client" boundary sits Comparison of a page whose client boundary sits at the layout level with one whose client components are small interactive leaves. Where the "use client" boundary sits Boundary at the top • Layout marked use client • Everything below becomes client code • Markdown parser + date lib shipped • Client JS ~ same as before RSC Boundaries at the leaves • Layout and content are server components • Only buttons, forms, menus are client • Heavy libraries stay on the server • Client JS cut by 50-80%

Rapid Diagnosis

  • Read the build output. next build lists "First Load JS" per route; compare routes with similar UI.
  • Search for "use client". Each occurrence is a boundary; boundaries in layouts, providers or large page components are suspects.
  • Analyse the client bundle with @next/bundle-analyzer and look for libraries that only render static output (markdown, syntax highlighting, date formatting, icons).
  • Check the RSC payload size. In the Network panel, RSC responses (?_rsc= requests or the inline flight data) can be large for data-heavy pages.

Root Cause Analysis

1. High client boundaries. Marking a layout or page as a client component to use one hook (useState for a menu, usePathname for active links) converts its entire subtree's imports into client code.

2. Context providers at the root. Providers must be client components; wrapping the whole app in a client provider is fine as long as children are passed as props (server components can be children of client components) — but importing server-only content into a provider file makes it client code.

3. Shared utility modules. A utility imported by both server and client components ships to the client with all its dependencies, even if the client uses one function.

4. Large serialised props. Passing big data objects from server to client components inflates the RSC payload and the hydration work for those components.

First Load JS for a blog post route Bar chart of first-load JavaScript for a blog post route in a Pages Router app, an App Router app with a high client boundary, and one with leaf client components. First Load JS for a blog post route Pages Router (all client) 214KB App Router, use client in layout 198KB App Router, client leaves only 92KB

Step-by-Step Resolution

1. Push "use client" down to interactive leaves

jsx
// app/blog/[slug]/page.jsx — a server component (no directive).
import { renderMarkdown } from '@/lib/markdown';      // heavy parser stays on the server
import { LikeButton } from './LikeButton';            // small client component
export default async function Post({ params }) {
  const post = await getPost(params.slug);
  return (<article>
    <h1>{post.title}</h1>
    <div dangerouslySetInnerHTML={{ __html: await renderMarkdown(post.body) }} />
    <LikeButton postId={post.id} />
  </article>);
}
// LikeButton.jsx starts with "use client" and contains only the button logic.
// trade-off: splitting interactivity into leaf components adds files and props
// plumbing. Worth it for content-heavy pages; less so for highly interactive
// dashboards where most of the tree is genuinely client-side.

Expected outcome: the markdown library and the article rendering code disappear from the client bundle.

2. Pass server components as children of client providers

jsx
// app/layout.jsx (server component)
import { ThemeProvider } from './ThemeProvider';    // "use client" inside
export default function RootLayout({ children }) {
  return <html><body><ThemeProvider>{children}</ThemeProvider></body></html>;
}
// trade-off: anything the provider file itself imports becomes client code.
// Keep provider files small and import nothing but the context they provide.

Expected outcome: the provider is client-side; the pages inside it remain server components.

3. Split shared utilities by environment

Separate server-only utilities (import 'server-only') from client-safe ones so imports cannot leak heavy server code into client bundles, and so accidental imports fail the build.

4. Trim props crossing the boundary

Pass client components only the fields they render. Large objects serialised into the RSC payload cost bytes and parse time even though no component code ships.

What reaches the browser in an RSC app Layers of what is sent to the browser for a page built with React Server Components. What reaches the browser in an RSC app HTML server-rendered markup for the whole page (fast first paint) RSC payload serialised server component output and props for client components Client component JS only modules reachable from use client boundaries Hydration work only client components hydrate; server output is not re-rendered

Verification

Compare next build First Load JS per route before and after, and confirm in the bundle analyser that heavy libraries no longer appear in client chunks. In a throttled trace, hydration should be limited to small client components, and TBT should fall. Check the RSC payload size has not ballooned in exchange — if it has, trim props.

Worked Example: A Documentation Site Migration

A documentation site migrated from the Pages Router to the App Router but initially put "use client" in its docs layout to keep a sidebar's expand/collapse state. First Load JS stayed at 205KB per page, because the layout imported the MDX renderer, syntax highlighter and search index client-side. Moving the sidebar state into a small SidebarToggle client component and keeping the layout as a server component dropped First Load JS to 88KB; lab TBT on docs pages fell from 380ms to 120ms, and field INP p75 improved from 230ms to 150ms.

Common Mistakes

  • Marking a page client-side to use one hook. Extract the hook's UI into a leaf component instead.
  • Importing server utilities into client components. Use the server-only package to fail fast.
  • Fetching on the client what could be fetched on the server. Client data fetching brings fetch libraries and loading states into the bundle.
  • Ignoring the RSC payload. It is not free; very large payloads delay hydration and use bandwidth.

Edge Cases

Third-party components without directives. Libraries that use hooks but lack "use client" must be wrapped in your own client component before use in server components.

Context-heavy libraries. UI libraries that rely on React context throughout may force large client boundaries; prefer libraries that work with server components or isolate them to interactive regions.

Streaming and Suspense. Server components can stream with Suspense, improving TTFB and LCP; client components inside streamed regions hydrate when their code and data arrive.

Caching. Server components shift work to the server; without caching, TTFB can rise. Cache data and use static rendering where possible.

FAQ

Do server components eliminate hydration?

For server components, yes — their output is not hydrated. Client components still hydrate. The total hydration cost becomes proportional to the interactive part of the page instead of the whole page.

Is the RSC payload larger than the JavaScript it replaces?

Usually much smaller, because it contains rendered output rather than code and libraries. It can grow large for pages that pass big data objects to client components; measure it alongside JavaScript size.

Does this help INP or only load time?

Both. Less client code means less parse, compile and hydration work during load, which reduces input delay for early interactions. Smaller client trees also re-render faster on state changes.

Can I adopt server components incrementally?

In Next.js, the App Router and Pages Router coexist, so routes can migrate one at a time. Within an App Router route, start by keeping pages as server components and extracting interactive pieces into client components.

How do I find which import pulled a library into the client bundle?

Use the bundle analyser to find the library in a client chunk, then search for imports of it in files reachable from "use client" boundaries. The import chain usually leads to a shared utility or a component that should have stayed on the server.