Server and Client Components live in different worlds—one on the server, one in the browser. When you pass props between them, data must cross this boundary through serialization. Not all JavaScript values can make this journey. Some data types work perfectly, others need transformation, and some simply can't cross at all. Understanding these rules is essential for building Next.js applications that work reliably and efficiently. Let's master the art of passing data between Server and Client Components!
The Serialization Boundary
When props cross from Server to Client Components, they're serialized to JSON:
The Journey of Props
- Server Component: Creates data
- Serialization: Converts to JSON string
- Transfer: Sends to browser in HTML
- Deserialization: Converts back to JavaScript
- Client Component: Receives data
JSON Serialization
Props are serialized using JSON.stringify() and deserialized with JSON.parse(). This means props must be JSON-compatible.
✅ Can Be Serialized
- Strings
- Numbers
- Booleans
- null
- Arrays
- Plain objects
❌ Cannot Be Serialized
- Functions
- Date objects (becomes string)
- undefined
- Symbol
- Map / Set
- Class instances
Basic Prop Passing
Simple Data Types
// Server Component
import { ClientComponent } from '@/components/ClientComponent';
export default async function Page() {
return (
<ClientComponent
title="Hello World" // ✅ String
count={42} // ✅ Number
isActive={true} // ✅ Boolean
tags={['react', 'nextjs']} // ✅ Array
config={{ theme: 'dark' }} // ✅ Plain object
empty={null} // ✅ null
/>
);
}'use client';
interface ClientComponentProps {
title: string;
count: number;
isActive: boolean;
tags: string[];
config: { theme: string };
empty: null;
}
export function ClientComponent({
title,
count,
isActive,
tags,
config,
empty,
}: ClientComponentProps) {
return (
<div>
<h1>{title}</h1>
<p>Count: {count}</p>
<p>Active: {isActive ? 'Yes' : 'No'}</p>
<ul>
{tags.map(tag => (
<li key={tag}>{tag}</li>
))}
</ul>
<p>Theme: {config.theme}</p>
</div>
);
}
// ✅ All props are JSON-serializable
// ✅ TypeScript ensures type safetyComplex Data Structures
Nested Objects and Arrays
// Server Component
import { BlogList } from '@/components/BlogList';
interface Post {
id: string;
title: string;
author: {
name: string;
avatar: string;
bio: string;
};
tags: string[];
metadata: {
views: number;
likes: number;
comments: number;
};
}
async function getPosts(): Promise<Post[]> {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div>
<h1>Blog Posts</h1>
{/* Complex nested data structure */}
<BlogList posts={posts} />
</div>
);
}
// ✅ Complex nested structure
// ✅ All values are JSON-serializable
// ✅ TypeScript provides type safety'use client';
interface Author {
name: string;
avatar: string;
bio: string;
}
interface Post {
id: string;
title: string;
author: Author;
tags: string[];
metadata: {
views: number;
likes: number;
comments: number;
};
}
export function BlogList({ posts }: { posts: Post[] }) {
return (
<div className="space-y-6">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-6">
<h2 className="text-2xl font-bold mb-2">{post.title}</h2>
<div className="flex items-center gap-3 mb-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
<div>
<p className="font-semibold">{post.author.name}</p>
<p className="text-sm text-gray-600">{post.author.bio}</p>
</div>
</div>
<div className="flex gap-2 mb-4">
{post.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
<div className="flex gap-6 text-sm text-gray-600">
<span>👁️ {post.metadata.views} views</span>
<span>❤️ {post.metadata.likes} likes</span>
<span>💬 {post.metadata.comments} comments</span>
</div>
</article>
))}
</div>
);
}What Cannot Be Passed
❌ Functions
// ❌ BAD: Cannot pass functions
function ServerComponent() {
const handleClick = () => {
console.log('clicked');
};
return <ClientComponent onClick={handleClick} />; // ❌ Error!
}
// ✅ GOOD: Define functions in Client Component
'use client';
function ClientComponent() {
const handleClick = () => {
console.log('clicked');
};
return <button onClick={handleClick}>Click</button>; // ✅ Works!
}❌ Class Instances
class User {
constructor(public name: string) {}
greet() {
return `Hello, ${this.name}`;
}
}
// ❌ BAD: Cannot pass class instances
function ServerComponent() {
const user = new User('Alice');
return <ClientComponent user={user} />; // ❌ Methods lost!
}
// ✅ GOOD: Pass plain object
function ServerComponent() {
const user = {
name: 'Alice',
// Include any needed data as plain values
};
return <ClientComponent user={user} />; // ✅ Works!
}⚠️ Date Objects (Special Case)
Date objects are serialized to ISO strings. You need to convert back to Date in the Client Component:
// Server Component
function ServerComponent() {
const post = {
title: 'My Post',
publishedAt: new Date('2024-01-15'), // Date object
};
return <ClientComponent post={post} />;
}
// What Client Component receives
'use client';
function ClientComponent({ post }) {
// post.publishedAt is now a STRING, not a Date!
console.log(typeof post.publishedAt); // "string"
// ✅ Convert back to Date
const date = new Date(post.publishedAt);
return (
<div>
<h2>{post.title}</h2>
<time>{date.toLocaleDateString()}</time>
</div>
);
}❌ undefined
// ❌ BAD: undefined becomes null
function ServerComponent() {
return (
<ClientComponent
value={undefined} // Becomes null in Client Component!
/>
);
}
// ✅ GOOD: Use null explicitly or omit the prop
function ServerComponent() {
return (
<ClientComponent
value={null} // Explicit null
// Or don't pass the prop at all
/>
);
}Handling Dates Properly
Pattern 1: Serialize to ISO String
// Server Component
interface Event {
id: string;
title: string;
startDate: string; // ISO string
endDate: string; // ISO string
}
async function getEvents(): Promise<Event[]> {
const events = await db.events.findMany();
// Convert Dates to ISO strings
return events.map(event => ({
...event,
startDate: event.startDate.toISOString(),
endDate: event.endDate.toISOString(),
}));
}
export default async function EventsPage() {
const events = await getEvents();
return <EventList events={events} />;
}'use client';
interface Event {
id: string;
title: string;
startDate: string; // Receives as string
endDate: string; // Receives as string
}
export function EventList({ events }: { events: Event[] }) {
return (
<div className="space-y-4">
{events.map(event => {
// Convert strings back to Dates
const start = new Date(event.startDate);
const end = new Date(event.endDate);
return (
<div key={event.id} className="border rounded p-4">
<h3 className="font-bold">{event.title}</h3>
<p className="text-sm text-gray-600">
{start.toLocaleDateString()} - {end.toLocaleDateString()}
</p>
<p className="text-sm text-gray-600">
Duration: {Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))} days
</p>
</div>
);
})}
</div>
);
}Pattern 2: Utility Functions
// Utility to serialize dates
export function serializeDate(date: Date): string {
return date.toISOString();
}
// Utility to deserialize dates
export function deserializeDate(dateString: string): Date {
return new Date(dateString);
}
// Serialize object with dates
export function serializeDates<T extends Record<string, any>>(
obj: T,
dateKeys: (keyof T)[]
): T {
const serialized = { ...obj };
for (const key of dateKeys) {
if (obj[key] instanceof Date) {
serialized[key] = obj[key].toISOString() as any;
}
}
return serialized;
}
// Usage
const post = {
title: 'My Post',
publishedAt: new Date(),
updatedAt: new Date(),
};
const serialized = serializeDates(post, ['publishedAt', 'updatedAt']);Handling Large Datasets
Problem: Too Much Data
Passing large datasets as props increases HTML size and hydration time.
Solution 1: Pass Only What's Needed
// ❌ BAD: Passing entire dataset
async function Page() {
const products = await db.products.findMany(); // 10,000 products
return <ProductList products={products} />; // ❌ Huge HTML!
}
// ✅ GOOD: Pagination
async function Page({ searchParams }) {
const page = Number(searchParams.page) || 1;
const limit = 20;
const products = await db.products.findMany({
skip: (page - 1) * limit,
take: limit,
});
return <ProductList products={products} page={page} />; // ✅ Only 20 items
}Solution 2: Pass IDs, Fetch Client-Side
// Server Component - pass only IDs
async function Page() {
const productIds = await db.products.findMany({
select: { id: true },
});
const ids = productIds.map(p => p.id);
return <ProductList productIds={ids} />;
}
// Client Component - fetch details when needed
'use client';
export function ProductList({ productIds }) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const loadProducts = async (ids: string[]) => {
setLoading(true);
const res = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify({ ids }),
});
const data = await res.json();
setProducts(data);
setLoading(false);
};
return (
<div>
<button onClick={() => loadProducts(productIds.slice(0, 10))}>
Load First 10
</button>
{loading && <p>Loading...</p>}
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Solution 3: Streaming with Suspense
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* Load immediately */}
<QuickContent />
{/* Load asynchronously */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</div>
);
}
async function ProductList() {
const products = await getProducts();
return (
<div>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}TypeScript Best Practices
Define Shared Types
// Shared types for Server and Client
export interface Author {
id: string;
name: string;
avatar: string;
bio: string;
}
export interface Post {
id: string;
title: string;
content: string;
author: Author;
publishedAt: string; // ISO string, not Date
tags: string[];
metadata: {
views: number;
likes: number;
comments: number;
};
}
// Type for database (with Date objects)
export interface PostDB {
id: string;
title: string;
content: string;
author: Author;
publishedAt: Date; // Date object in DB
tags: string[];
metadata: {
views: number;
likes: number;
comments: number;
};
}
// Conversion utility
export function serializePost(post: PostDB): Post {
return {
...post,
publishedAt: post.publishedAt.toISOString(),
};
}Use Branded Types for Safety
// Branded type for ISO date strings
type ISODateString = string & { readonly __brand: 'ISODateString' };
function toISODateString(date: Date): ISODateString {
return date.toISOString() as ISODateString;
}
interface Event {
id: string;
title: string;
date: ISODateString; // Clear this is a date string
}
// TypeScript ensures you convert dates properly
const event: Event = {
id: '1',
title: 'Conference',
date: toISODateString(new Date()), // ✅ Must convert
// date: '2024-01-15' // ❌ TypeScript error without conversion
};Validate Props at Runtime
'use client';
import { z } from 'zod';
// Define schema
const PostSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
publishedAt: z.string().datetime(), // Validates ISO format
tags: z.array(z.string()),
});
type Post = z.infer<typeof PostSchema>;
export function BlogPost({ post }: { post: Post }) {
// Validate at runtime (optional but helpful)
const validated = PostSchema.parse(post);
return (
<article>
<h1>{validated.title}</h1>
<div dangerouslySetInnerHTML={{ __html: validated.content }} />
</article>
);
}Common Patterns
Pattern 1: Configuration Objects
// Server Component
function Page() {
const config = {
theme: 'dark',
language: 'en',
features: {
comments: true,
sharing: true,
analytics: false,
},
};
return <App config={config} />;
}
// Client Component uses config for behavior
'use client';
export function App({ config }) {
return (
<div className={config.theme === 'dark' ? 'dark' : 'light'}>
{config.features.comments && <Comments />}
{config.features.sharing && <ShareButtons />}
</div>
);
}Pattern 2: Initial State
// Server Component fetches initial data
async function Page() {
const initialTodos = await getTodos();
return <TodoList initialTodos={initialTodos} />;
}
// Client Component uses initial data, then manages state
'use client';
import { useState } from 'react';
export function TodoList({ initialTodos }) {
const [todos, setTodos] = useState(initialTodos);
const addTodo = (text: string) => {
setTodos([...todos, { id: Date.now(), text, done: false }]);
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
<button onClick={() => addTodo('New todo')}>Add</button>
</div>
);
}Pattern 3: Metadata for Client Logic
// Server Component
async function Page() {
const user = await getUser();
// Pass metadata for client-side logic
return (
<Dashboard
userId={user.id}
isPremium={user.isPremium}
permissions={user.permissions}
preferences={user.preferences}
/>
);
}
// Client Component uses metadata to determine behavior
'use client';
export function Dashboard({ userId, isPremium, permissions, preferences }) {
return (
<div>
{permissions.includes('admin') && <AdminPanel />}
{isPremium ? <PremiumFeatures /> : <FreeFeatures />}
<UserSettings preferences={preferences} />
</div>
);
}Debugging Serialization Issues
Error: Objects Are Not Valid
// ❌ Common error
// Error: Objects are not valid as a React child
// Problem: Trying to render an object directly
function Component({ data }) {
return <div>{data}</div>; // ❌ If data is object
}
// ✅ Solution: Access properties
function Component({ data }) {
return <div>{data.title}</div>; // ✅ Render string
}
// ✅ Or use JSON.stringify for debugging
function Component({ data }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}Check Serialization Manually
// Test if data can be serialized
const data = {
title: 'Test',
date: new Date(),
onClick: () => {}, // ❌ Function!
};
try {
const serialized = JSON.stringify(data);
const deserialized = JSON.parse(serialized);
console.log(deserialized);
// date is now a string
// onClick is missing (functions can't be serialized)
} catch (error) {
console.error('Cannot serialize:', error);
}Add Development Warnings
'use client';
export function ClientComponent({ data }) {
// Warn in development if data isn't what you expect
if (process.env.NODE_ENV === 'development') {
if (typeof data.date === 'string') {
console.warn('date prop is string, convert to Date:', data.date);
}
if (data.onClick) {
console.warn('onClick prop should be defined in Client Component');
}
}
return <div>{data.title}</div>;
}Serialization Examples
Examples of passing different data types between components
Select a file or folder to see details
Key Takeaways
- Props are serialized to JSON - must be JSON-compatible
- Simple types work fine - strings, numbers, booleans, arrays, objects
- Functions cannot be passed - define in Client Component
- Dates become strings - convert back with new Date()
- Class instances lose methods - pass plain objects
- undefined becomes null - use null explicitly
- Large datasets need strategies - pagination, streaming, lazy loading
- Use TypeScript - shared types ensure consistency
What's Next?
You've mastered prop passing and serialization between Server and Client Components! The final lesson in this series covers Server Component Patterns and Best Practices—advanced patterns, performance optimization, and architectural guidance for building production-ready applications.
You'll learn patterns like data fetching strategies, caching patterns, error handling approaches, and how to structure large applications for maintainability and performance. This is where everything comes together!
🔍 Debug Early
When props aren't working as expected, check serialization first. A quick JSON.stringify() test can save hours of debugging. Remember: if it can't be JSON.stringify'd, it can't cross the Server-Client boundary!