Overview

React Server Components (RSC) render on the server and send HTML and a serialized component tree to the client, with no client-side JavaScript for the server portion. Migrating to RSC reduces bundle size, eliminates client-side data-fetching waterfalls, and simplifies authentication checks. The deep-dive rules live in react-server-components; the architectural tradeoffs are in server-vs-client-components.

Prerequisites

  • Next.js 13.4+ with the App Router enabled. RSC is available only in the App Router, not the Pages Router.
  • The project builds without errors before you start. Migrate on a green baseline.
  • A way to measure bundle size: ANALYZE=true next build with @next/bundle-analyzer configured.

Steps

1. Audit the component tree for client dependencies

Server components cannot use browser APIs, React hooks (useState, useEffect, useContext), or event handlers. Start by listing what each component needs.

Create a simple audit:

# Find components using hooks or event handlers
grep -r "useState\|useEffect\|useCallback\|onClick\|onChange" src/components/ --include="*.tsx" -l

These files must remain client components or be split so the interactive part is isolated. Everything else is a candidate for server components.

2. Identify the boundary: push “use client” as deep as possible

The "use client" directive marks a subtree as client-rendered. Place it as deep in the tree as possible to maximize the server-rendered surface area.

Before migration (everything is a client component by default in the Pages Router):

// components/ProductPage.tsx
"use client";
import { useState } from "react";
 
export function ProductPage({ productId }: { productId: string }) {
  const [qty, setQty] = useState(1);
  // ... fetch product data client-side
}

After migration, split at the boundary:

// app/products/[id]/page.tsx  — SERVER component (no directive)
import { db } from "@/lib/db";
import { AddToCart } from "@/components/AddToCart";
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({ where: { id: params.id } });
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCart productId={product.id} price={product.price} />
    </div>
  );
}
 
// components/AddToCart.tsx  — CLIENT component
"use client";
import { useState } from "react";
 
export function AddToCart({ productId, price }: { productId: string; price: number }) {
  const [qty, setQty] = useState(1);
  // interactive logic lives here only
}

See server-component for the definition and constraints.

3. Refactor data fetching to async server components

Server components can be async and await database or API calls directly; no useEffect or getServerSideProps needed.

// Before (client-side fetch)
"use client";
export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
  }, [userId]);
  if (!user) return <Spinner />;
  return <div>{user.name}</div>;
}
 
// After (server component, async)
import { db } from "@/lib/db";
export async function UserProfile({ userId }: { userId: string }) {
  const user = await db.user.findUnique({ where: { id: userId } });
  return <div>{user.name}</div>;
}

Eliminate the loading spinner; use Next.js loading.tsx for Suspense boundaries instead. See nextjs-app-router for the file conventions.

4. Handle context and shared state

Server components cannot consume React context. Move shared state that crosses the server/client boundary to URL search params, cookies, or a client component wrapper that reads from context and passes props down.

// Wrap a context consumer in a small client component
"use client";
import { useTheme } from "@/context/theme";
export function ThemedButton({ children }: { children: React.ReactNode }) {
  const { theme } = useTheme();
  return <button className={theme}>{children}</button>;
}
// The parent page that renders ThemedButton can still be a server component.

5. Measure the bundle impact

ANALYZE=true next build

The bundle analyzer opens in the browser. Confirm that data-fetching libraries (SWR, React Query) and server-only packages (Prisma client, pg) no longer appear in the client bundle. See react-performance for bundle size targets. The hydration page explains what remains in the client bundle and why.

Verify it worked

# Build passes without errors
next build
 
# No "use server" imports in client bundles
ANALYZE=true next build
# Confirm Prisma, pg, axios are absent from client chunks.
 
# Network tab shows HTML with content (not blank RSC shell)
# Open browser DevTools > Network > filter JS; check bundle count dropped.

Common errors

  • Error: Hooks can only be called inside a function component. A server component imported a module that calls a hook at the module level. Move it to a client component.
  • Cannot pass a Server Component as a prop to a Client Component. Server components can be passed as children to client components but not as arbitrary props. Restructure so the server component wraps the client component.
  • Context value is undefined in server components. Context does not work in server components. Pass the value as props or move the consumer to a client component.
  • Database query runs on every render. Next.js does not cache async server component fetches by default in App Router v14+. Use unstable_cache or React’s cache() function for deduplication.
  • TypeScript reports await in non-async function. Add async to the component function signature; server components support async directly.