I resisted React Server Components for a year. Every explanation I read started with the RSC wire format and ended with a diagram of a bundler. What finally made it click was a much simpler framing: you now have two kinds of components with two different jobs, and one rule about how they nest. This post is that framing, with the code and the mistakes I made migrating a client dashboard.
Two kinds of components
| Server Component | Client Component | |
|---|---|---|
| Where it runs | Server only (request time or build time) | Server (for initial HTML) and browser |
| JavaScript shipped | None for the component itself | Yes, the component and its imports |
| Can use | async/await, databases, file system, secrets, heavy libraries | useState, useEffect, event handlers, browser APIs |
| Cannot use | Hooks with state or effects, event handlers, browser APIs | Direct server resources (database, fs, secret env vars) |
| How you opt in | Default in the Next.js App Router | "use client" at the top of the file |
The mental model that works: a Server Component is a template that runs on the server and produces a description of UI. A Client Component is an island of interactivity inside that description. The framework serialises the server output (the RSC payload), streams it to the browser, and React fills in the islands with real, hydrated components.
The one nesting rule
Here is the rule that resolves almost every "why doesn't this work" question:
A Client Component cannot import a Server Component. But a Client Component can render a Server Component that is passed to it as a prop (usually children).
Why: once you are inside a "use client" file, everything it imports is bundled for the browser. A Server Component imported there would lose its server-only powers. But a Server Component rendered by the parent and passed down as a prop has already run on the server; the client component just receives its output.
// components/Collapsible.tsx -- CLIENT: owns open/closed state
'use client';
import { useState, type ReactNode } from 'react';
export function Collapsible({ title, children }: { title: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(o => !o)} aria-expanded={open}>{title}</button>
{open && <div>{children}</div>}
</section>
);
}
// app/orders/page.tsx -- SERVER: fetches data, passes a server-rendered subtree as children
import { Collapsible } from '@/components/Collapsible';
import { OrderTable } from '@/components/OrderTable'; // a Server Component that queries the DB
export default async function OrdersPage() {
return (
<Collapsible title="Recent orders">
<OrderTable limit={20} /> {/* runs on the server, arrives as ready-made UI */}
</Collapsible>
);
}
OrderTable can await a Postgres query and it will never ship to the browser, even though it is visually inside a client component. This composition trick is what keeps bundles small.
Data fetching moves into the component
The pattern that ate half my client-side code in the old dashboard was: useEffect → set loading → fetch → set data → handle error → render. In a Server Component the same thing is:
// components/OrderTable.tsx (Server Component; no directive needed)
import { sql } from '@/lib/db';
import { formatCurrency, formatDate } from '@/lib/format'; // stays on the server
export async function OrderTable({ limit }: { limit: number }) {
const orders = await sql`
SELECT id, customer_name, total_cents, created_at
FROM orders ORDER BY created_at DESC LIMIT ${limit}`;
if (orders.length === 0) return <p>No orders yet.</p>;
return (
<table>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{o.customer_name}</td>
<td>{formatCurrency(o.total_cents)}</td>
<td>{formatDate(o.created_at)}</td>
</tr>
))}
</tbody>
</table>
);
}
No loading state (Suspense handles it), no error state in the component (an error.tsx boundary handles it), no API route for the browser to call, and the date-fns and currency helpers never enter the client bundle. On the dashboard migration this removed an API layer of 14 routes and cut the client JavaScript by about 40%.
Streaming with Suspense
Wrap slow Server Components in <Suspense> and the page shell streams immediately while the slow part arrives later:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<>
<Header /> {/* instant */}
<Suspense fallback={<TableSkeleton rows={8} />}>
<OrderTable limit={20} /> {/* streams in when the query finishes */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* independent; streams separately */}
</Suspense>
</>
);
}
This is why RSC apps feel fast on slow connections: the browser gets meaningful HTML in the first flight, and each slow region resolves independently. It also does good things for LCP, because the hero content is not waiting on the slowest query on the page.
Keep client boundaries low in the tree
The most common mistake I see (and made): slapping "use client" on a page because one button needs onClick. That drags the entire page, including its data formatting and every imported library, into the browser bundle. Instead, push the directive down to the smallest component that needs interactivity.
// WRONG: whole page is now client code
'use client';
export default function ProductPage({ product }) { /* ...big page... */ }
// RIGHT: only the button is client code
// components/AddToCart.tsx
'use client';
export function AddToCart({ productId }: { productId: string }) {
const [pending, setPending] = useState(false);
return <button disabled={pending} onClick={async () => { setPending(true); await addToCart(productId); setPending(false); }}>Add to cart</button>;
}
// app/products/[slug]/page.tsx (server)
export default async function ProductPage({ params }) {
const product = await getProduct(params.slug);
return (
<article>
<ProductGallery images={product.images} /> {/* server */}
<ProductDetails product={product} /> {/* server */}
<AddToCart productId={product.id} /> {/* client island */}
</article>
);
}
Props across the boundary must be serialisable
Anything you pass from a Server Component into a Client Component travels over the wire, so it has to be JSON-like: strings, numbers, plain objects, arrays, Dates (handled), and Server Actions. Not functions, not class instances, not a database client. If TypeScript lets you pass a function and Next.js throws at runtime, this is why.
Server Actions close the loop
Mutations no longer need an API route either. A function marked "use server" can be imported into a client component and called like a normal async function; the framework turns it into an RPC.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const Input = z.object({ productId: z.string().uuid(), qty: z.number().int().min(1).max(10) });
export async function addToCart(raw: unknown) {
const { productId, qty } = Input.parse(raw); // ALWAYS validate: this is a public endpoint
const session = await getSession();
if (!session) throw new Error('unauthenticated');
await sql`INSERT INTO cart_items (user_id, product_id, qty) VALUES (${session.userId}, ${productId}, ${qty})
ON CONFLICT (user_id, product_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty`;
revalidatePath('/cart');
}
Treat Server Actions exactly like REST endpoints for security: validate input, check auth, rate limit. They are discoverable and callable by anyone who can reach your site. The Zod-at-the-boundary pattern applies unchanged.
Migrating an existing client-side React app
- Move the app into the App Router with
"use client"on every existing component. Nothing changes yet; it just runs. - Convert layouts and pages to Server Components first. They rarely have state.
- Replace
useEffectdata fetching in leaf components with async Server Components, keeping only the interactive parts as client islands underneath. - Delete the API routes that only existed to feed the client. Keep the ones third parties use.
- Replace form submission handlers with Server Actions once the data layer is server-side.
Order matters. Doing step 3 before step 2 leaves you fighting the nesting rule constantly.
When RSC is the wrong tool
Highly interactive, state-heavy UIs (design tools, real-time dashboards, games) gain little; nearly everything is a client island anyway. Sites with no server (pure static hosting) cannot run request-time Server Components, though build-time ones still work. And if your backend is PHP and you are adding React to existing pages rather than building a Next.js app, the incremental client-only approach is still the right call; RSC needs a React server to exist.
For content-heavy, data-driven sites, which is most of what agencies build, RSC is a clear win once the model is in your head. The caching behaviour that comes with it is its own topic, and it bit me hard the first time, so I wrote it up separately: Next.js App Router Caching Guide.