
Practical patterns for React Server Components: where to place the server/client boundary, composing Server and Client Components, avoiding data fetching waterfalls, streaming with Suspense, serialization rules, and common pitfalls.
Aurora Scharff
March 12, 2026
Server Components let you render components on the server with zero client-side JavaScript cost. But knowing what they are and knowing how to structure a real application with them are two different things.
This post covers practical patterns for working with React Server Components: how to think about the server/client boundary, composition strategies, data fetching approaches, streaming, and the common pitfalls that trip people up.
For an introduction to Server Components and other server-side React features, see React Frameworks and Server-Side Features.
The most important mental shift with Server Components is deciding where the server/client boundary lives. In Next.js, every component in your app is a Server Component by default. You only add 'use client' when a component needs interactivity, browser APIs, or React hooks like useState and useEffect. Other frameworks may handle this differently, but the examples in this post use Next.js conventions.
A common mistake is adding 'use client' too high in the component tree. If you mark a layout component as a Client Component, every component it imports also becomes a Client Component, pulling unnecessary code into the client bundle.
Instead, push 'use client' as far down the tree as possible. Keep the data-fetching, layout-building parts as Server Components and isolate interactivity into small, focused Client Components.
That said, don't be afraid of 'use client'. It's not a bad thing. React applications need interactivity, and Client Components are how you provide it. The goal is not to avoid 'use client' entirely — it's to use it intentionally, so you're only shipping JavaScript for the parts that actually need it.
Consider a product page:
// page.jsx — Server Component (default)
import { ProductDetails } from './product-details';
import { AddToCartButton } from './add-to-cart-button';
import { ReviewSection } from './review-section';
export default async function ProductPage({ params }) {
const product = await db.products.findUnique({
where: { slug: params.slug },
});
return (
<main>
<ProductDetails product={product} />
<AddToCartButton productId={product.id} price={product.price} />
<ReviewSection productId={product.id} />
</main>
);
}
Here, ProductDetails can stay a Server Component since it only displays data. AddToCartButton needs 'use client' because it handles click events and manages cart state. ReviewSection might be a Server Component that fetches reviews and renders a Client Component for the review form inside it.
The boundary is not a wall between two halves of your app. It is a line you draw around the specific pieces that need the browser.
One of the most useful patterns with Server Components is passing them as children to Client Components. This lets you keep server-rendered content while wrapping it in interactive behavior.
Here's the problem: once a component has 'use client', everything it imports becomes client code. But props like children are not imported. They are passed in from the parent, which means they can still be Server Components.
Here's an Accordion Client Component that accepts children:
// accordion.jsx
'use client';
import { useState } from 'react';
export function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
And here's a Server Component that passes server-fetched content through children:
// page.jsx — Server Component
import { Accordion } from './accordion';
export default async function FAQPage() {
const faqs = await db.faqs.findMany();
return (
<div>
{faqs.map((faq) => (
<Accordion key={faq.id} title={faq.question}>
<p>{faq.answer}</p>
</Accordion>
))}
</div>
);
}
The Accordion is a Client Component that handles the toggle state. But the content inside it was fetched and rendered on the server. No extra JavaScript was shipped for the FAQ data or the paragraph elements.
This pattern is especially useful for:
The key insight: Client Components can render Server Components through children and other JSX props, but they cannot import and render Server Component modules directly.
Server Components can be async, which means you can await data directly inside them. This is powerful, but it introduces the risk of request waterfalls when components fetch data sequentially.
When a parent component fetches data and then renders a child that also fetches data, the child's request doesn't start until the parent's request finishes:
// page.jsx — sequential requests (waterfall)
export default async function DashboardPage() {
const user = await getUser(); // 200ms
return (
<div>
<h1>Welcome, {user.name}</h1>
<UserPosts userId={user.id} />
</div>
);
}
async function UserPosts({ userId }) {
const posts = await getPostsByUser(userId); // 300ms — starts after getUser finishes
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Total time: 500ms, because the requests run one after the other.
When requests don't depend on each other, fetch them in parallel. Here's how you can do this within a single component using Promise.all:
export default async function DashboardPage({ params }) {
const [user, posts, stats] = await Promise.all([
getUser(params.userId),
getPosts(params.userId),
getStats(params.userId),
]);
return (
<div>
<h1>Welcome, {user.name}</h1>
<PostList posts={posts} />
<StatsPanel stats={stats} />
</div>
);
}
Now all three requests start at the same time, and the total time equals the slowest request rather than the sum.
When parallel requests belong to separate components, you can use Suspense to let each component fetch its own data independently. By wrapping sibling components in their own Suspense boundaries, React renders them in parallel rather than waiting for one to finish before starting the next:
import { Suspense } from 'react';
export default function DashboardPage({ params }) {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={params.userId} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<UserStats userId={params.userId} />
</Suspense>
</div>
);
}
Notice how DashboardPage is no longer async. It doesn't fetch anything itself. Each child component fetches its own data, and Suspense handles the loading state for each one independently. If posts load faster than stats, the user sees posts immediately while stats show a skeleton.
This is the recommended pattern: let each component own its data requirements, and use Suspense to orchestrate loading states declaratively.
Streaming works hand-in-hand with Suspense. When a Server Component suspends, the server sends the already-rendered HTML and continues processing the suspended part. Once it resolves, the server streams the result to the browser, which swaps out the fallback automatically.
This means users see content as it becomes ready rather than waiting for the slowest part of the page.
For the dashboard example above, the browser receives the page shell and heading immediately. Posts might stream in after 200ms, and stats after 400ms. Each piece appears in place without a full page reload.
You can nest Suspense boundaries to create fine-grained loading sequences. Here's a product page where product details load before reviews:
export default function ProductPage({ params }) {
return (
<main>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails slug={params.slug} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews slug={params.slug} />
</Suspense>
</Suspense>
</main>
);
}
The outer boundary catches ProductDetails. Once it resolves, the inner boundary is revealed, showing a reviews skeleton while reviews load. This creates a natural content hierarchy where the most important content appears first.
When a Server Component passes props to a Client Component, those props must be serializable. React needs to send the data from the server to the client, and it can only send values that can be converted to a format the network can carry.
Serializable values include:
Date objectsMap and Set instances'use server')Values that are not serializable:
This means you can't pass an event handler from a Server Component to a Client Component. This won't work:
async function ProductPage() {
const product = await getProduct();
const handleAddToCart = () => {
addToCart(product.id);
};
return <AddToCartButton onClick={handleAddToCart} />;
}
The handleAddToCart function is a plain function, which is not serializable. Instead, pass the data the Client Component needs and let it define its own handlers. Here's the Server Component passing just the product ID:
// Server Component
async function ProductPage() {
const product = await getProduct();
return <AddToCartButton productId={product.id} />;
}
And here's the Client Component using that data to define its own click handler:
// Client Component
'use client';
import { useState } from 'react';
import { addToCart } from './cart-utils';
export function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
const handleClick = () => {
addToCart(productId);
setAdded(true);
};
return (
<button onClick={handleClick}>
{added ? 'Added ✓' : 'Add to Cart'}
</button>
);
}
The most common mistake is reaching for 'use client' as soon as a file touches anything interactive. Before adding it, ask: can the interactive part be extracted into a smaller component?
// Don't make the whole page a Client Component just for one interactive piece
'use client'; // ← unnecessary
import { useState } from 'react';
export default function SettingsPage() {
const [theme, setTheme] = useState('light');
return (
<div>
<h1>Settings</h1>
<p>Manage your account settings and preferences.</p>
{/* ... many more static elements */}
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</div>
);
}
Instead, keep the page as a Server Component and extract just the toggle:
// page.jsx — Server Component
import { ThemeToggle } from './theme-toggle';
export default function SettingsPage() {
return (
<div>
<h1>Settings</h1>
<p>Manage your account settings and preferences.</p>
{/* ... many more static elements */}
<ThemeToggle />
</div>
);
}
// theme-toggle.jsx
'use client';
import { useState } from 'react';
export function ThemeToggle() {
const [theme, setTheme] = useState('light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}
All the static content stays out of the client bundle. And because the page itself remains a Server Component, it can still fetch data directly, use async/await, and follow the same component model as the rest of your app. If the whole page were a Client Component, you'd have to either fetch data higher up and pass it down, or resort to client-side fetching.
If a module accesses server-only resources like environment variables or database connections, accidentally importing it in a Client Component will fail. The server-only package from npm helps catch this at build time:
npm install server-only
// db.js
import 'server-only';
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();
Now, if any Client Component imports db, the build will fail with a clear error message instead of producing a confusing runtime error.
There's also a client-only package for the opposite scenario, when you want to ensure a module is never imported on the server.
Server Components render on the server and their output is sent to the client as a static result. They do not re-render when client-side state changes. If you need a component to respond to useState, useContext, or other client-side state, it needs to be a Client Component.
When you want a Server Component to show updated data after a mutation, use router refresh methods like router.refresh() in Next.js or revalidation APIs to trigger a new server render.
'use client' down the tree to minimize client-side JavaScript, but don't be afraid to use it when you need interactivityPromise.all or independent Suspense boundaries to avoid request waterfallsserver-only package to prevent accidental server code imports in Client ComponentsServer Components change the way you think about building React applications. The most important skill is recognizing where the server/client boundary belongs: keep data fetching and static rendering on the server, and isolate interactivity into small Client Components. You can still fetch data on the client when it makes sense, but that's outside the scope of this post.
The composition patterns covered here, particularly passing Server Components through children, are what make this architecture flexible in practice. Combined with parallel data fetching and Suspense for streaming, you can build applications that ship less JavaScript and load progressively without sacrificing the component model React developers are used to.
Sources:
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

React Server Components in Practice: Patterns and Pitfalls
Practical patterns for React Server Components: where to place the server/client boundary, composing Server and Client Components, avoiding data fetching waterfalls, streaming with Suspense, serialization rules, and common pitfalls.
Aurora Scharff
Mar 12, 2026

Building a Blog app with Nuxt Content and Agentic AI
Nuxt and Agentic AI: MCPs explained, setting up a Nuxt project with Claude Code, guiding the agent with good prompts (bad vs good prompt examples), MCP context-awareness, finishing and polishing
Reza Baar
Mar 10, 2026

What Does Zoneless Angular Mean?
Explore what “zoneless” Angular means—how change detection works without Zone.js, what triggers updates instead, and the best practices (Signals, OnPush, async pipe) to get ready.
Alain Chautard
Mar 5, 2026
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.
