React Server Components: What They Actually Solve and When to Avoid Them

React Server Components shipped into production with Next.js 13's App Router and have been the subject of significant confusion ever since. Part of the confusion is linguistic: "server components" sounds like "server-side rendering," which React has supported for years via ReactDOM.renderToString. But RSC is architecturally different from SSR — different enough that conflating them leads to wrong decisions about when to reach for them.

The clearest way to understand RSC is to start with the specific problem they were designed to address, not the feature list. Once you understand the problem, the constraints and limitations become logical consequences rather than arbitrary rules.

The Specific Problem RSC Solves

Every React component you write today ships to the browser as JavaScript. Even if you use SSR — rendering a page to HTML on the server before sending it — the component code still travels to the client for hydration. The browser re-runs your component tree to attach event listeners and make the page interactive. This means your database query logic, your data transformation code, your heavy utility libraries: all of it goes in the JavaScript bundle even if the browser never needs to re-execute it.

Consider a product page component that fetches from a database, formats a price with a locale-aware library, parses markdown, and renders a list. In a traditional React app, these dependencies — the database client, the formatting library, the markdown parser — all bundle into the client JavaScript, even though the browser can never use a database connection directly and the formatting only needs to happen once at render time.

RSC's answer: make the component server-only. A Server Component runs on the server, returns HTML (not a JavaScript component), and its source code and dependencies never reach the browser. The browser receives rendered HTML output, not the component itself.

// Server Component — this file never ships to the browser
// You can import heavy libs freely here
import { db } from '@/lib/database';
import { formatCurrency } from 'heavy-i18n-library'; // stays on server
import { parseMarkdown } from 'markdown-parser';      // stays on server

export default async function ProductDetail({ id }) {
  const product = await db.products.findUnique({ where: { id } });
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{formatCurrency(product.price, 'en-IN')}</p>
      <div dangerouslySetInnerHTML={{ __html: parseMarkdown(product.description) }} />
    </div>
  );
}

The heavy-i18n-library and markdown-parser imports contribute zero bytes to the client bundle. The database query executes without an API round-trip. The component renders on the server and sends HTML to the browser — done.

What Server Components Cannot Do

The constraints on Server Components follow directly from the fact that they run exclusively on the server and return HTML rather than interactive component trees. They are hard limits, not configuration options.

Server Components cannot use React hooks. useState, useEffect, useRef, useContext — all unavailable. The reason is that these hooks manage browser-side state and lifecycle events that have no equivalent on the server. A component that needs to track user input or respond to scroll events must be a Client Component.

Server Components cannot attach event handlers. onClick, onChange, onSubmit: any interactivity requiring a JavaScript callback requires a Client Component. You can pass server-fetched data as props to a Client Component, but the interaction logic itself must live in Client Component code.

Server Components cannot use browser APIs. window, document, localStorage, navigator: none of these exist in a server execution context. Code that reads screen dimensions, manages browser history, or accesses cookies via document.cookie must go in Client Components.

The Server/Client Boundary and Serialisation

When a Server Component renders a Client Component as a child, props cross the server/client boundary. This boundary has one rigid rule: props must be serialisable. Plain objects, strings, numbers, arrays, and Dates can cross. Functions, class instances, React refs, and non-serialisable objects cannot.

// ✅ Valid — data is serialisable
<ProductCard
  name={product.name}
  price={product.price}
  imageUrl={product.imageUrl}
/>

// ❌ Invalid — function cannot cross the boundary
<ProductCard
  onAddToCart={() => handleCartUpdate(product.id)} // Error!
/>

// ✅ Valid — move the handler into the Client Component itself
// ProductCard.tsx ('use client')
export default function ProductCard({ name, price, productId }) {
  function handleAddToCart() {
    // Client-side logic here
  }
  return <button onClick={handleAddToCart}>Add to Cart</button>;
}

Understanding this boundary prevents the most common RSC errors. When you see "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'" — that's the serialisation boundary being enforced.

One important nuance: Server Components can pass other Server Components as children via the children prop without crossing the boundary. This lets you compose layouts where the outer shell is a Client Component (for scroll handling, for example) but the content inside remains server-rendered.

// Layout.tsx ('use client') — for scroll tracking
'use client';
export default function ScrollAwareLayout({ children }) {
  const [scrolled, setScrolled] = useState(false);
  useEffect(() => {
    const handler = () => setScrolled(window.scrollY > 50);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  return <div className={scrolled ? 'scrolled' : ''}>{children}</div>;
}

// page.tsx (Server Component) — passes server-rendered content as children
import ScrollAwareLayout from './Layout';
import ProductList from './ProductList'; // Server Component

export default async function Page() {
  return (
    <ScrollAwareLayout>
      <ProductList /> {/* Still a Server Component */}
    </ScrollAwareLayout>
  );
}

Practical Patterns That Work Well

The architectural pattern that works consistently across App Router applications: Server Components own data fetching and static rendering; Client Components own interactivity. The interface between them is a props boundary carrying serialisable data.

Data Fetching in Server Components

Replace useEffect + fetch patterns with direct async calls in Server Components. No loading states needed on the server — the component suspends automatically while awaiting data, and React streams the result when it's ready. For components that previously used SWR or React Query solely to fetch server data on mount, this pattern eliminates both the client-side data-fetching library and the intermediate loading state from the user's visible experience.

Keeping Secrets Server-Side

API keys, database credentials, and internal service URLs used in Server Components never reach the client. This is a meaningful security improvement over client-side data fetching, where an API key in a fetch call (even if stored in an environment variable) can leak if the component accidentally ends up in the client bundle. With RSC, the risk doesn't exist because the component and its environment variable references never leave the server.

Reducing Prop Drilling With Direct DB Access

In deeply nested component trees, RSC lets a leaf component fetch exactly the data it needs without requiring every ancestor component to pass it down. A UserAvatar component five levels deep can query the user record directly rather than receiving it as a prop from a top-level page component. This reduces the coupling between component levels.

When RSC Creates More Complexity Than It Removes

RSC is not the right default for every component. Several categories of UI genuinely require client execution, and forcing them into a server/client split adds complexity without benefit.

Highly Interactive UIs

A drag-and-drop kanban board, a rich text editor, a drawing canvas, a real-time collaborative document: these UIs have no meaningful server-renderable content because their state changes continuously in response to user input. Wrapping them in Server Components to fetch their initial data is reasonable; trying to make the interactive parts server-rendered is not. These components should be "use client" throughout.

Real-Time Features

WebSocket connections, live cursors, collaborative presence indicators, chat interfaces: all of these rely on persistent browser-side connections and event-driven state updates. RSC has no streaming mutation model for these use cases. Server Components run once and return HTML; they cannot subscribe to real-time events. Use Client Components with WebSockets, Server-Sent Events, or libraries like Pusher, Ably, or Supabase Realtime.

Components That Need Browser Context at Init

A component that reads the viewport size to decide its layout, accesses localStorage for a user preference, or checks navigator.language for localisation must run in the browser. These aren't cases where you can "move the logic to the server" — the data simply doesn't exist on the server at request time. Attempting to server-render these components results in hydration mismatches: the server renders a guess, the client corrects it, and the user sees a flash or layout shift.

The Accidental Client Bundle Risk

One RSC pitfall deserves explicit attention: importing a server-only module from a Client Component. If a utility file exports both a database function and a formatting helper, and a Client Component imports just the formatting helper, the entire module — including the database import — may end up in the client bundle depending on how the bundler handles the tree-shaking.

The server-only package solves this. Add it as an import at the top of any module that should never reach the client:

import 'server-only'; // Throws a build-time error if imported in a client bundle
import { db } from './database-client';

export async function getUserById(id: string) {
  return db.users.findUnique({ where: { id } });
}

This build-time guard catches accidental imports before they become production security issues. Any team working with RSC at scale should add server-only to every module that accesses environment secrets, database connections, or internal services.

Frequently Asked Questions

Do React Server Components replace Redux or Zustand?

For server-fetched data, yes. RSC eliminates the need to store API responses in a global client store. If your Redux or Zustand store primarily holds data fetched from an API (user profile, product list, cart from the backend), a Server Component can fetch and render that data directly without touching the client-side store at all. But for client-side interaction state — UI toggles, form input state, modal visibility, optimistic UI updates — Server Components have no involvement. useState and client-side libraries remain correct for state that exists solely in response to user interaction. The practical outcome: server state moves out of the global store into Server Components; client interaction state stays in Client Components with Zustand or useState.

Can I use Server Components with third-party libraries like Framer Motion?

No, not directly. Framer Motion and similar animation libraries rely on browser APIs and React hooks that are unavailable in Server Components. Importing Framer Motion in a Server Component throws a build-time error. The workaround: wrap the animated element in its own Client Component file with "use client", then import that Client Component into your Server Component. The Server Component handles data fetching and layout; the Client Component handles animation. This wrapper pattern is the standard approach for any library requiring browser context — isolate the client-dependent code in a thin "use client" file and keep everything else server-side.

Are Server Components supported in Vite or Create React App projects?

No. RSC requires framework-level infrastructure to handle the server/client boundary, the RSC wire format, and the build pipeline separation between server and client bundles. Vite and Create React App do not provide this. As of 2026, Next.js App Router is the primary production-ready RSC implementation. Remix added experimental RSC support. For standard Vite-based React projects, there is no RSC support without significant custom build tooling. If RSC is a requirement, the project needs Next.js App Router or a framework that has explicitly built RSC support into its architecture.