
React Server Components Deep Dive: Serialization Rules and Real Patterns
Passing a function as props from a Server Component broke everything. Understanding the serialization boundary revealed RSC's true patterns.

Passing a function as props from a Server Component broke everything. Understanding the serialization boundary revealed RSC's true patterns.
Why is the CPU fast but the computer slow? I explore the revolutionary idea of the 80-year-old Von Neumann architecture and the fatal bottleneck it left behind.

ChatGPT answers questions. AI Agents plan, use tools, and complete tasks autonomously. Understanding this difference changes how you build with AI.

Integrating a payment API is just the beginning. Idempotency, refund flows, and double-charge prevention make payment systems genuinely hard.

When you don't want to go yourself, the proxy goes for you. Hide your identity with Forward Proxy, protect your server with Reverse Proxy. Same middleman, different loyalties.

I started using Server Components in a Next.js project. It seemed straightforward—fetch data on the server and pass it down to Client Components.
// app/page.tsx (Server Component)
async function Home() {
const posts = await fetchPosts();
const handleClick = () => {
console.log('Clicked!');
};
return <PostList posts={posts} onClick={handleClick} />;
}
Error. "Functions cannot be passed directly to Client Components." What's wrong? I just passed a function.
Fixing this error changed how I understood React Server Components. Components run on the server, and their output travels across the network to the client. At that boundary, serialization happens. Functions can't be serialized. That's why it fails.
Once I understood this simple fact, all the RSC patterns started making sense.
Between Server Components and Client Components, there's an invisible wall. When data crosses this wall, it needs to be converted like JSON. This is called serialization.
Sending data from server to client is like shipping a package. You can only send what fits in the box.
// ❌ Things that can't be serialized
<ClientComponent
onClick={() => {}} // function
user={new User('John')} // class instance
date={new Date()} // Date object
promise={fetchData()} // Promise
/>
// ✅ Things that can be serialized
<ClientComponent
count={42} // number
name="John" // string
user={{ name: 'John', id: 1 }} // plain object
tags={['react', 'nextjs']} // array
/>
Understanding this rule connected everything—why React team created the "use client" directive, why the children pattern matters, all of it clicked.
At first, I thought "use client" meant "this component runs on the client." Wrong.
// app/components/Counter.tsx
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
"use client" actually means "this is a serialization boundary. This file and everything it imports goes into the client bundle."
Conversely, "use server" creates Server Actions.
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ title });
revalidatePath('/posts');
}
This means "this function always runs on the server. When called from the client, it works like RPC." It crosses the serialization boundary in reverse.
If you can't pass functions as props, how do you pass logic from Server Components to Client Components? You don't. You use composition instead.
// ❌ Don't do this
// app/page.tsx (Server)
async function Page() {
const posts = await fetchPosts();
const handleClick = () => {}; // can't create a function
return <PostList posts={posts} onClick={handleClick} />; // and pass it
}
// ✅ Do this
// app/page.tsx (Server)
async function Page() {
const posts = await fetchPosts();
return (
<PostList>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</PostList>
);
}
// app/components/PostList.tsx (Client)
'use client';
export function PostList({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState('all');
return (
<div>
<FilterButtons onFilterChange={setFilter} />
<div>{children}</div>
</div>
);
}
Like Russian nesting dolls (Matryoshka), Server Components can wrap Client Components, which can wrap Server Components again. Server → Client → Server composition works.
Why? Because children is already rendered output (React Elements), not functions. It's data. It can be serialized.
The biggest advantage of Server Components is that the component itself can be an async function.
// app/dashboard/page.tsx
async function DashboardPage() {
// Parallel data fetching
const [user, posts, analytics] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchAnalytics(),
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<AnalyticsChart data={analytics} />
</div>
);
}
You can also solve waterfall problems.
// ❌ Waterfall problem (sequential execution)
async function Page() {
const user = await fetchUser();
const posts = await fetchPosts(user.id); // waits for user
return <PostList posts={posts} />;
}
// ✅ Streaming with Suspense
async function Page() {
const userPromise = fetchUser();
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostList userPromise={userPromise} />
</Suspense>
</div>
);
}
async function PostList({ userPromise }: { userPromise: Promise<User> }) {
const user = await userPromise;
const posts = await fetchPosts(user.id);
return <div>{posts.map(post => <PostCard post={post} />)}</div>;
}
Pass the same Promise to multiple components, and React automatically deduplicates. It fetches once.
Next.js's fetch is automatically cached.
// Default: infinite cache (Static)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
// Revalidate every 10 seconds (ISR)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 10 }
});
return res.json();
}
// No caching (Dynamic)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: 'no-store'
});
return res.json();
}
Like Redis or CDN caching, data is cached at multiple levels. The difference is this happens automatically at the component level.
Manual revalidation is also possible.
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({ title: formData.get('title') });
// Invalidate cache for specific path
revalidatePath('/posts');
// Or invalidate by tag
revalidateTag('posts');
}
// Tag data fetching
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return res.json();
}
What if you accidentally import server code on the client? Security problem. DB passwords could end up in the bundle.
// lib/db.ts
import 'server-only'; // Error if imported on client
export async function query(sql: string) {
// DB access code
return await db.query(sql);
}
There's also a client-only package.
// lib/analytics.ts
import 'client-only';
export function trackEvent(name: string) {
if (typeof window !== 'undefined') {
gtag('event', name);
}
}
Like TypeScript type checking, it catches mistakes at build time.
The biggest advantage of Server Components is they don't go into the client bundle.
// app/page.tsx (Server)
import { marked } from 'marked'; // 31KB library
import { format } from 'date-fns'; // 68KB library
async function BlogPost({ slug }: { slug: string }) {
const post = await fetchPost(slug);
const html = marked(post.content);
const formattedDate = format(new Date(post.date), 'PPP');
return (
<article>
<h1>{post.title}</h1>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
marked and date-fns don't go into the client bundle. They run on the server, and only the HTML result goes to the client. That's 99KB saved.
Same thing in a Client Component?
'use client';
import { marked } from 'marked'; // goes into client bundle
import { format } from 'date-fns'; // goes into client bundle
export function BlogPost({ post }: { post: Post }) {
const html = marked(post.content);
const formattedDate = format(new Date(post.date), 'PPP');
return (
<article>
<h1>{post.title}</h1>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
Users download 99KB more. On mobile, that's critical.
// ❌ Doesn't work
// app/page.tsx (Server)
import { useContext } from 'react';
async function Page() {
const theme = useContext(ThemeContext); // Error!
return <div className={theme}>...</div>;
}
Fix: Make Context Provider a Client Component, pass Server Component as children
// ✅ Fixed
// app/providers.tsx (Client)
'use client';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.tsx (Server)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// ❌ Doesn't work as expected
// app/components/ClientWrapper.tsx (Client)
'use client';
import { ServerStats } from './ServerStats'; // Server Component
export function ClientWrapper() {
const [show, setShow] = useState(false);
return show ? <ServerStats /> : null; // Works but ServerStats becomes Client Component
}
Fix: Pass as children or props
// ✅ Fixed
// app/components/ClientWrapper.tsx (Client)
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [show, setShow] = useState(false);
return show ? children : null;
}
// app/page.tsx (Server)
async function Page() {
return (
<ClientWrapper>
<ServerStats /> {/* Stays as Server Component */}
</ClientWrapper>
);
}
// ❌ Doesn't work
'use client';
export async function UserProfile() { // Error!
const user = await fetchUser();
return <div>{user.name}</div>;
}
Fix: Fetch in parent Server Component or use useEffect
// ✅ Fix 1: Fetch in parent
// app/page.tsx (Server)
async function Page() {
const user = await fetchUser();
return <UserProfile user={user} />;
}
// ✅ Fix 2: useEffect (not recommended, waterfall problem)
'use client';
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
What I learned from using Server Components is that this isn't just a feature addition. It changed React's mental model.
The core is the serialization boundary. When data crosses from server to client, it needs to be converted like JSON. Functions don't work. Classes don't work. This constraint created all the patterns.
"use client" doesn't mean "runs on client"—it marks the "serialization boundary""use server" serializes functions in reverse (like RPC)And the performance benefits are massive. Server Components don't go into the client bundle. Heavy libraries can be used server-only. Users get a faster app.
The constraint of not being able to pass a function as props created better architecture. Sometimes constraints become paths forward. I learned that lesson again.