React Server Components in Practice: Patterns and Pitfalls

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

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.

Thinking About the Boundary

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.

Composition: Passing Server Components as Children

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:

  • Modals and dialogs that wrap server-fetched content
  • Tab panels where each tab's content comes from the server
  • Layout components like sidebars or collapsible panels that add interactivity around server content

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.

Data Fetching: Avoiding Waterfalls

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.

The Waterfall Problem

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.

Parallel Fetching

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.

Parallel Fetching with Suspense

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: Progressive Page Loading

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.

Serialization: What Can Cross the Boundary

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:

  • Primitives: strings, numbers, booleans, null, undefined
  • Plain objects and arrays containing serializable values
  • Date objects
  • Map and Set instances
  • Server Functions (functions marked with 'use server')
  • JSX elements (including other Server Components)

Values that are not serializable:

  • Functions (except Server Functions)
  • Classes and class instances
  • Symbols
  • Closures

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>
  );
}

    

Common Pitfalls

Adding 'use client' Too Early

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.

Importing Server-Only Code in Client Components

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.

Expecting Server Components to Re-render on State Changes

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.

Key Takeaways

  • Push 'use client' down the tree to minimize client-side JavaScript, but don't be afraid to use it when you need interactivity
  • Use the children pattern to combine interactive Client Components with server-rendered content
  • Fetch data in parallel with Promise.all or independent Suspense boundaries to avoid request waterfalls
  • Props crossing the server/client boundary must be serializable; use Server Functions for callbacks
  • Use the server-only package to prevent accidental server code imports in Client Components
  • Server Components don't re-render on client state changes; use router refresh or revalidation for updates

Conclusion

Server 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:

More certificates.dev articles

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.

Looking for Certified Developers?

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.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)