DEV Community

Cover image for Next.js Doesn't Render Pages Anymore. It Streams Something Stranger.
S M Tahosin
S M Tahosin

Posted on

Next.js Doesn't Render Pages Anymore. It Streams Something Stranger.

If you ask ten developers what the "use client" directive does in Next.js, at least eight of them will give you the same intuitive answer:

"Server Components run on the server. Client Components run in the browser. So 'use client' tells Next.js to run the component on the client."

It sounds completely logical. The directive is literally named use client. The mental model seems clean: two environments, two directives, a neat split between backend and frontend.

Except that is not how Next.js works at all.

If Client Components only ran in the browser, why does this code crash your server with ReferenceError: window is not defined?

"use client";

export default function UserProfile() {
  // πŸ’₯ Crashes during build or initial page load on the server!
  const screenWidth = window.innerWidth;

  return <div>Screen width: {screenWidth}px</div>;
}
Enter fullscreen mode Exit fullscreen mode

If it were truly client-side only, window would be defined, the browser would evaluate it, and there would be no error on the server.

The truth is surprising to many developers: Client Components in Next.js also execute on the server.

The "use client" directive does not define where a component executes. It defines a module boundary that dictates what code gets packaged into your browser bundle.

To write fast, bug-free Next.js applications, you have to dismantle the "server vs. client execution" myth and understand what the rendering engine is actually doing behind the scenes.

Next.js rendering flow: How Server Components and Client Components execute across the Server, RSC Payload, Initial HTML, and Browser Hydration


The Core Misconception: Execution vs. Bundling

In the App Router, every file is a Server Component by default.

When you prepend "use client" to the top of a file, you are not saying "never run this on Node.js."

You are telling the bundler:

"Everything exported from this file, and anything it imports, needs to be compiled into a JavaScript bundle and sent across the network to the browser. Also, allow this sub-tree to use React hooks (useState, useEffect) and browser event listeners (onClick)."

When a user visits a route for the first time, Next.js performs Server-Side Pre-rendering (SSR) for your entire pageβ€”including your Client Components.

Here is what happens during that initial request:

  1. Server Components execute on the server. They can access databases, read files, read secret environment variables, and compute data. They output a stream of structured UI instructions called the RSC Payload.
  2. Client Components ALSO execute on the server. Next.js executes their render function once on the server to generate semantic HTML tags (<div class="profile">...</div>).
  3. The browser receives raw HTML immediately. The user sees the fully formed page almost instantly (First Contentful Paint).
  4. The JavaScript bundle downloads in the background. React runs in the browser, matches the JavaScript event listeners to the existing DOM nodes, and brings the page to life. This second step is called Hydration.
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚             INITIAL HTTP REQUEST              β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                       β”‚
                         Next.js Server Execution
                                       β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β–Ό                                                             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       Server Component        β”‚             β”‚       Client Component        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€             β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ β€’ Runs ON SERVER ONLY         β”‚             β”‚ β€’ Runs ON SERVER (Initial SSR)β”‚
β”‚ β€’ Zero JS sent to browser     β”‚             β”‚ β€’ Bundled into client JS      β”‚
β”‚ β€’ Direct DB / Secrets access  β”‚             β”‚ β€’ Re-runs in browser          β”‚
β”‚ β€’ Emits: RSC Payload          β”‚             β”‚ β€’ Emits: HTML + JS Bundle     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                β”‚                                             β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                       β”‚
                        Combined Server Response
                                       β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό                                                 β–Ό
    Raw Pre-rendered HTML                          RSC Payload Stream
  (Instant paint on screen)                    (Virtual DOM data instructions)
              β”‚                                                 β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                       β”‚
                              Browser Environment
                                       β”‚
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β–Ό                             β–Ό
                 Fast First Paint              React Hydration
              (Non-interactive HTML)         (Attaches events & hooks)
Enter fullscreen mode Exit fullscreen mode

Once you see this diagram, the window is not defined error makes immediate sense. When Next.js pre-rendered your Client Component on the server to generate that initial HTML, Node.js evaluated window.innerWidth. Because Node.js has no browser window, it blew up before the HTML ever left the server.


What Actually Is the RSC Payload?

Most developers know what HTML is, and they know what JavaScript is. But Next.js introduces a third artifact that is the true backbone of the App Router: the React Server Component (RSC) Payload.

When the server evaluates your Server Components, it does not produce HTML directly. Instead, it generates a compact, streaming binary/JSON format representing the virtual tree.

Here is a conceptual look at what an RSC Payload stream contains:

  1. Rendered HTML-like UI descriptions of all Server Components (tags, attributes, text).
  2. Placeholders ("holes") where Client Components should go.
  3. References to JavaScript chunk URLs that the browser needs to load to fill those client holes.
  4. Serialized props passed from Server Components into Client Components.

Next.js Component Tree and Boundary: Server root passing down data to Client boundary nodes

Let's look at a concrete example. Suppose you have this page:

// app/dashboard/page.tsx (Server Component by default)
import Counter from './Counter';

export default async function DashboardPage() {
  const user = await db.user.findUnique({ where: { id: 1 } });

  return (
    <main>
      <h1>Welcome back, {user.name}</h1>
      <Counter initialCount={user.savedItemsCount} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

And your counter component:

// app/dashboard/Counter.tsx
"use client";

import { useState } from 'react';

export default function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Saved Items: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

When Next.js renders DashboardPage, it executes the database query on the server. The code for db.user.findUnique never leaves the server. The database driver is not in your browser bundle.

Next.js then serializes the result into an RSC stream that looks conceptually like this:

[
  "$", "main", null, {
    "children": [
      ["$", "h1", null, { "children": "Welcome back, Sarah" }],
      ["$", "$L1", null, { "initialCount": 5 }]
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

Notice ["$", "$L1", null, { "initialCount": 5 }].

$L1 is a reference placeholder. Elsewhere in the stream, Next.js specifies:

1:I["./app/dashboard/Counter.tsx", ["chunks/counter.js"], "default"]
Enter fullscreen mode Exit fullscreen mode

The server says: "At this spot in the tree, place the component exported from Counter.tsx. You will find its code in chunks/counter.js. And give it the prop initialCount: 5."

RSC Payload: Server Component tree serialized into streaming JSON instructions and client bundle references

Why does Next.js stream this payload instead of just sending plain HTML?

Because when a user navigates between pages on the client (client-side routing), Next.js does not re-download a full HTML page. It requests only the new route's RSC Payload!

React merges the new RSC Payload directly into the existing browser DOM. Any client-side state in parent components (like an active dropdown, playing audio, or scroll position) stays preserved, while the server components update seamlessly.


The Boundary Rule: What Can Cross the Line?

Because Server Components run in an isolated server context and Client Components run across both environments, the border between them has strict physics.

1. Props Must Be Serializable

When a Server Component passes props to a Client Component, those props must travel across the network inside the RSC Payload.

That means props must be serializable to JSON (with React-extended support for Promises, BigInt, Date, and Set/Map):

// ❌ FAILS: Functions cannot be serialized across the network
<ClientButton onClick={() => console.log("clicked")} />

// ❌ FAILS: Classes with methods lose their prototype
<ClientUserCard user={new UserModel("Sarah")} />

// βœ… WORKS: Plain objects, strings, numbers, booleans, arrays
<ClientUserCard user={{ name: "Sarah", role: "admin" }} />

// βœ… WORKS: Server Actions (marked with "use server")
<ClientButton action={deleteAccountAction} />
Enter fullscreen mode Exit fullscreen mode

If you try to pass an inline callback function from a Server Component to a Client Component, Next.js throws an error: Functions cannot be passed directly to Client Components unless you explicitly expose it with "use server".

2. The Import Flow Is One-Way Only

You can import a Client Component into a Server Component. But you cannot import a Server Component directly into a Client Component.

"use client";

// ❌ This does NOT work as a Server Component anymore!
import ServerSecretWidget from './ServerSecretWidget';

export default function ClientModal() {
  return (
    <div>
      <ServerSecretWidget />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why? Because the moment you import a file into a file marked "use client", the bundler treats that imported file as part of the client bundle too!

If ServerSecretWidget used private database credentials, the bundler would either throw a compilation error or, worse, attempt to bundle backend modules into the browser.


The Slot Pattern: Putting Server Components Inside Client Components

If Client Components cannot import Server Components, how do you put a data-fetching Server Component inside a Client Component (like an interactive animated modal or collapsible sidebar)?

This is where the Composition Pattern (or Slot Pattern) comes in, and it is one of the most elegant architectural patterns in React.

Instead of importing the Server Component into the Client Component, you pass it as a children prop:

// components/CollapsibleSection.tsx
"use client";

import { useState, ReactNode } from 'react';

export default function CollapsibleSection({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);

  return (
    <section>
      <button onClick={() => setIsOpen(prev => !prev)}>
        {isOpen ? "Collapse" : "Expand"}
      </button>
      {isOpen && <div>{children}</div>}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, in your Server Component:

// app/page.tsx (Server Component)
import CollapsibleSection from '@/components/CollapsibleSection';
import HeavyDatabaseList from '@/components/HeavyDatabaseList';

export default async function Page() {
  return (
    <main>
      <h1>Project Feed</h1>
      {/* HeavyDatabaseList stays a Server Component! */}
      <CollapsibleSection>
        <HeavyDatabaseList />
      </CollapsibleSection>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

How does this work?

HeavyDatabaseList is evaluated on the server by Page.tsx. Its output is converted into the RSC Payload.

CollapsibleSection receives the already-resolved React elements as children. The code for HeavyDatabaseList never touches the browser bundle.

CollapsibleSection handles the interactive toggle in the browser without ever knowing or caring what rendered inside its slot.


Three Costly Mistakes Developers Make (And How to Fix Them)

Understanding the rendering pipeline protects you from the three most common performance killers and bugs in Next.js:

Mistake 1: Placing "use client" at the Root of a Feature

When developers need a single useState or onClick handler, they often slap "use client" at the very top of the page or layout.

// ❌ Anti-pattern: The entire subtree becomes client-side bundled
"use client";

import { useState } from 'react';
import HeavyChart from './HeavyChart'; // 120 kB bundled!
import LargeDataList from './LargeDataList'; // 85 kB bundled!

export default function AnalyticsDashboard() {
  const [filter, setFilter] = useState("all");

  return (
    <div>
      <FilterDropdown value={filter} onChange={setFilter} />
      <HeavyChart filter={filter} />
      <LargeDataList filter={filter} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Push the client boundary down to the leaves.

Keep the container as a Server Component. Extract only the interactive input into a small Client Component:

// βœ… Better: Only the interactive trigger is a client component
// app/dashboard/FilterDropdown.tsx
"use client";

export function FilterDropdown({ current, onSelect }) { ... }
Enter fullscreen mode Exit fullscreen mode

Keep heavy static presentation or data-fetching logic in Server Components to keep the JavaScript bundle lean.

Mistake 2: Accessing Browser APIs Outside of useEffect

Because Client Components render on the server during the initial request, accessing window, document, or localStorage during the render phase causes runtime server crashes:

"use client";

export default function ThemeWidget() {
  // πŸ’₯ Error: window is not defined during SSR
  const theme = localStorage.getItem("theme") ?? "light";

  return <div>Current theme: {theme}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The Fix:

Move browser API access into useEffect (which only executes in the browser after mount), or wrap it with a mount guard:

"use client";

import { useState, useEffect } from 'react';

export default function ThemeWidget() {
  const [theme, setTheme] = useState<string>("light");

  useEffect(() => {
    // βœ… Safe: useEffect runs ONLY in the browser after hydration
    const saved = localStorage.getItem("theme");
    if (saved) setTheme(saved);
  }, []);

  return <div>Current theme: {theme}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Hydration Mismatches from Non-Deterministic Values

If the HTML produced on the server differs from what the client produces during its first hydration pass, React emits a noisy console warning:

Text content does not match server-rendered HTML.

Common triggers:

"use client";

export default function Timestamp() {
  // ⚠️ Server renders at 12:00:00.000, browser hydrates at 12:00:00.120!
  return <span>Current time: {new Date().toLocaleTimeString()}</span>;
}
Enter fullscreen mode Exit fullscreen mode

When React hydrates, it expects the server HTML and the initial client render tree to match character-for-character. If they disagree, React has to throw away the server HTML and rebuild the DOM tree from scratch, degrading performance.

For values that depend on the client's local time or random numbers, defer the dynamic rendering until after mounting, or use suppressHydrationWarning on individual tags where mismatches are intentional.


Quick Reference: Server vs. Client Components

Capability Server Component (Default) Client Component ("use client")
Initial Execution Server only Server (SSR) and Browser
Shipped to Client Bundle 0 kB JavaScript Full component code + imports
React Hooks (useState, useEffect) ❌ No βœ… Yes
Browser APIs (window, navigator) ❌ No βœ… Inside useEffect or events
Direct DB & Secret Key Access βœ… Yes ❌ No
Render Output RSC Payload (virtual stream) HTML string (SSR) + Client DOM
Primary Purpose Data fetching, security, zero bundle User interactivity, state, event handlers

The Mental Shift

Next.js does not split your application into two separate worlds. It weaves them together into a single continuous pipeline.

The next time you type "use client", remember:

You are not telling Next.js where to execute the code. You are drawing a line for the compiler, telling it where interactive JavaScript needs to begin, and allowing the server to hand off the user interface smoothly to the client.

Once you treat "use client" as a boundary rather than a location, the App Router transforms from a confusing maze of hydration errors into one of the most capable full-stack architectures in modern web development.

When you first started working with the Next.js App Router, what was the mental model hurdle that tripped you up the most? And what is your favorite pattern for keeping client bundles minimal?


References:

Top comments (1)

Collapse
 
shubham_works profile image
Shubham Chaudhary •

Thanks for collecting all the necessary information and putting it all together here. It well described the difference between Server, Client and React Server Components. Very well, simple, easily explained and it should clear most of the doubts devs mostly have; like it cleared mine.

One of the most important concepts to understand in Next.js App Router is β€œuse client” defines a boundary, not an execution location. Understanding the difference between server rendering, client bundling, and hydration changes how you architect your applications.

Great explanation! πŸ‘ Now I've got a little more to talk about in my next interview! πŸ˜„