Table of Contents

Understanding the boundaries of React Server Components (RSC) is one of the most critical shifts when moving from traditional client-side SPA frameworks to Next.js App Router.

Rather than executing all components in the browser, Next.js runs Server Components on the server and streams the resulting UI elements down to the client. This introduces a network and serialization boundary that dictates how we pass data, share state, and structure component trees.


1. Visualizing the Boundary

The boundary is unidirectional: Server Components can import and render Client Components, but Client Components cannot directly import and render Server Components as components. They can, however, receive Server Components as children or props.

graph TD subgraph SG1_ServerThread ["Server Thread"] A[Layout.tsx - RSC] --> B[Page.tsx - RSC] end subgraph SG2_ClientThread ["Client Thread"] C[Navbar.tsx - Client Component] D[InteractiveCard.tsx - Client Component] end B -->|Renders & Passes Serialized Props| D A -->|Renders| C

2. Serialization Rules: What Crosses the Boundary?

When a Server Component passes props to a Client Component, that data must be converted into a string-based protocol (the RSC Payload) that the browser can deserialize and reconstruct. This protocol is JSON-like but extends support for streaming elements.

Serializable Data (Supported)

  • Primitives: string, number, boolean, null, undefined, bigint.
  • Arrays & Plain Objects: { name: "John" } (must be pure key-value objects, not instantiated classes).
  • Promises: You can pass a pending promise from server to client, and the client can unpack it using the React 19 use() hook.
  • React Elements: Server Component JSX nodes (e.g. <ServerChild />) can be passed as props.
  • TypedArrays: Uint8Array, etc.

Non-Serializable Data (Unsupported)

  • Functions: Event handlers (e.g., onClick={handleClick}) cannot be passed across the boundary because code execution context cannot be serialized across threads.
  • Class Instances: Instantiated class models (e.g., custom database schemas or ORM entities) lose their prototype chain during serialization.
  • Symbols: Cannot be transferred across the network.
  • Circular Structures: Cause infinite loops during serialization.

3. Common Architectural Pitfalls

Pitfall A: The "Cannot Serialize Function" Error

This happens when you accidentally pass an event handler or callback from a Server Component to a Client Component.

// ❌ WRONG: Fails because handleClick cannot be serialized
export default async function ServerPage() {
  const handleClick = async () => {
    "use server";
    console.log("Clicked");
  };

  return <ClientButton onClick={handleClick} />;
}
//  CORRECT: Pass raw data, let the client component handle interactions locally
export default async function ServerPage() {
  const userId = "user_123";
  return <ClientButton userId={userId} />;
}

Pitfall B: Passing ORM Objects Directly

If you query a database using Prisma or Drizzle, the return values are often objects that contain non-serializable fields (like custom Date objects or nested prototype functions).

// ❌ WRONG: Might crash if task.createdAt is a raw Date object
const task = await db.task.findUnique({ id });
return <TaskDetailsCard task={task} />;
//  CORRECT: Sanitize and transform the Data Transfer Object (DTO)
const task = await db.task.findUnique({ id });
const sanitizedTask = {
  id: task.id,
  title: task.title,
  createdAt: task.createdAt.toISOString() // Explicitly convert date to string
};
return <TaskDetailsCard task={sanitizedTask} />;

4. State Sharing: Moving Beyond Context Providers

In a client-side SPA, developers share global state using Context Providers (useContext, Redux, Zustand) wrapped around the root layout.

In Next.js, wrapping your root layout in a Context Provider forces all children to become Client Components, completely disabling the benefits of Server Components for the entire application page.

The Architectural Solution: URL State

Instead of React state, use the URL query string as your primary state coordinator.

// components/filter-sidebar.tsx (Client Component)
"use client";

import { useRouter, useSearchParams } from "next/navigation";

export function FilterSidebar() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const handleFilterChange = (category: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("category", category);
    router.push(`?${params.toString()}`);
  };

  return (
    <div>
      <button onClick={() => handleFilterChange("tech")}>Tech</button>
      <button onClick={() => handleFilterChange("finance")}>Finance</button>
    </div>
  );
}
// app/dashboard/page.tsx (Server Component)
import { FilterSidebar } from "@/components/filter-sidebar";
import { fetchArticles } from "@/lib/db";

interface PageProps {
  searchParams: Promise<{ category?: string }>;
}

export default async function DashboardPage({ searchParams }: PageProps) {
  const { category } = await searchParams;
  
  // Data is fetched dynamically on the server based on URL state!
  const articles = await fetchArticles(category || "all");

  return (
    <div class="dashboard-layout">
      <FilterSidebar />
      <div class="article-grid">
        {articles.map(art => <ArticleCard data={art} />)}
      </div>
    </div>
  );
}

Benefits of URL State:

  1. Zero Client Javascript: The article cards remain pure Server Components; they don't load state engines in the browser.
  2. Bookmarkable Pages: Users can bookmark or share the URL, and it will load the exact filtered layout instantly.
  3. Instant SEO: Search engines index all filtered pages naturally because they render static HTML on load.