RSC is the biggest shift in React in years: components that render on the server and ship zero JS. Here is what actually happens, where the boundary lives, and the gotchas that bite.
You know React hooks, you have shipped a few apps, and now Next.js keeps telling you to add **'use client'** to make an error go away, but nobody explained *why*. This article is for the mid-to-senior frontend dev who wants the real mental model behind React Server Components (RSC) and the App Router, not just the recipe.
For a decade, React ran one place: the browser. The server's only job was to hand over an empty <div id="root"> and a bundle of JavaScript. RSC breaks that assumption. Now some components render on the server, once, and ship zero JavaScript to the browser. Others stay interactive in the browser like always. The hard part is not the syntax, it is knowing which is which, and what can cross the line between them.
If the words CSR, SSR, and SSG are fuzzy, read rendering strategies: CSR, SSR, SSG first, RSC is a new point on that same spectrum, not a replacement for it.
A mental model: the kitchen and the table
Before any code, anchor on one analogy. A restaurant has a kitchen and a dining table. The kitchen does heavy work once, out of sight, and sends out finished plates. The table is where you actually sit, rearrange your cutlery, and react in real time.
The kitchen plates the foodServer Components render to HTML + an RSC payload, then disappear
Ingredients and recipes stay in the kitchenSecrets, DB clients, and big libraries never reach the browser
The dining table where you sitClient Components hydrate and run in the browser
Rearranging cutlery, calling the waiteruseState, onClick, useEffect, interactivity lives here
Server Components do the heavy lifting once and send finished output; Client Components are where the interactivity lives.
A Server Component is a function that runs on the server, returns UI, and is never sent to the browser as code. A Client Component is the React you already know, it ships, hydrates, and runs in the browser.
What actually happens on a request
When a request hits the App Router, the server renders the Server Components, streams HTML for fast first paint, and ships a compact RSC payload describing the tree. The browser then hydrates only the Client Components inside it. Here is the flow.
Server renders RSC and streams HTML plus the RSC payload; the browser hydrates only the client islands.
1
Request arrives
The App Router maps the URL to a route segment and its layout/page tree. By default every component is a Server Component.
2
Server Components render
Async server components await their own data directly. No client JS is generated for them.
3
Stream the shell
HTML flushes to the browser as it is ready, Suspense boundaries let slow parts stream in later, so first paint is fast.
4
Ship the RSC payload
Alongside HTML, the server sends a serialized description of the tree, including holes where Client Components go.
5
Hydrate only the islands
The browser downloads JS for Client Components and wires up their interactivity. Server-only parts ship no JS at all.
Server vs Client Components, side by side
The whole model collapses into one table. Memorize it and most 'why does this error?' questions answer themselves. Server Components are the default; you opt a subtree into the client with 'use client'.
Capability
Server Component
Client Component
useState / useReducer
No
Yes
useEffect / lifecycle
No
Yes
Event handlers (onClick)
No
Yes
Browser APIs (window, localStorage)
No
Yes
Direct data fetch (await db, fs, secrets)
Yes
No, must call an API
Ships JavaScript to the browser
No
Yes
What each kind of component can and cannot do.
Default to the server
Start every component as a Server Component. Only reach for `'use client'` when you hit something in the right-hand column, state, an effect, an event handler, or a browser API. This keeps your JS bundle small by default.
Fetching data with no useEffect
The single most freeing thing about RSC: an async Server Component can just await its data. No useEffect, no loading flag, no useState, no waterfall of client requests. The component is async, you await, you render. If you have been managing server state by hand, compare this with the patterns in data fetching and server state.
app/dashboard/page.tsx
tsx
// Server Component (no 'use client' = runs on the server)import { db } from'@/lib/db';
import { LikeButton } from'./LikeButton';
exportdefaultasyncfunctionDashboardPage() {
// Direct data access. This code never reaches the browser,// so using the DB client and secrets here is safe.const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<section>
<h1>Latest posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<span>{post.title}</span>
{/* A client island for interactivity, nested in a server tree */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</li>
))}
</ul>
</section>
);
}
Notice LikeButton is imported and rendered inside a Server Component, but it carries 'use client'. That is the boundary. The server renders the list and passes plain, serializable props (postId, initialLikes) into the client island.
app/dashboard/LikeButton.tsx
tsx
'use client';
// This directive marks the file, and everything it imports, as client code.import { useState } from'react';
exportfunctionLikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
return (
<button onClick={() => setLikes((n) => n + 1)}>
\u2764 {likes}
</button>
);
}
The directive is a boundary, not a label
`'use client'` does not just mark *one* component. It marks the entry point: that file and every module it imports become part of the client bundle. Put it at the **leaf** that actually needs interactivity, not the top of your tree.
Mutations with Server Actions
Reads are easy now, but what about writes? Server Actions let you define an async function that runs on the server and can be called straight from a form or a client event, without you hand-writing an API route. Mark the function with 'use server', mutate, then revalidate the affected data so the UI reflects the change.
app/posts/actions.ts
tsx
'use server';
import { db } from'@/lib/db';
import { revalidatePath } from'next/cache';
import { auth } from'@/lib/auth';
exportasyncfunctioncreatePost(formData: FormData) {
const user = awaitauth();
if (!user) thrownewError('Unauthorized');
const title = String(formData.get('title') ?? '').trim();
if (!title) return { error: 'Title is required' };
await db.post.create({ data: { title, authorId: user.id } });
// Tell Next.js the cached render of this route is now stale.revalidatePath('/dashboard');
return { ok: true };
}
app/posts/NewPostForm.tsx
tsx
import { createPost } from'./actions';
// A Server Component can wire the action straight to the form's action prop.exportfunctionNewPostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" />
<button type="submit">Create</button>
</form>
);
}
The form posts to the action, the server mutates the database, and revalidatePath invalidates the cached render so the next view shows the new post. No fetch call, no client-side API client, no manual cache busting in your component.
The serialization boundary
Everything you pass from a Server Component into a Client Component crosses a wire. It gets serialized into the RSC payload. That puts a hard limit on what props are allowed.
You cannot pass functions or class instances across the boundary
Props flowing server → client must be serializable: strings, numbers, booleans, plain objects/arrays, dates, and JSX. You **cannot** pass a regular function, a class instance (like a Prisma model with methods), a Map/Set, or a Symbol. The one exception is a Server Action, React serializes it as a reference, not real code. If you need a callback, define the handler *inside* the Client Component, or pass a Server Action.
OK:<SaveButton onSave={saveAction} /> where saveAction is a 'use server' function.
Not OK:<Widget onClick={() => doThing()} /> from a Server Component, a closure cannot be serialized.
Not OK: passing a live DB client, a class instance, or a Date wrapped in a custom class.
Streaming and Suspense for fast TTFB
You do not have to wait for the slowest query before showing anything. Wrap a slow Server Component in <Suspense> and the server streams the surrounding shell immediately, then flushes the slow part when its data resolves. The user sees a meaningful page far sooner, a direct win for the metrics in core web vitals and frontend performance.
app/dashboard/page.tsx
tsx
import { Suspense } from'react';
import { SalesChart } from'./SalesChart';
exportdefaultfunctionPage() {
return (
<main>
<h1>Dashboard</h1>
{/* Shell paints now; the slow chart streams in when ready */}
<Suspense fallback={<p>Loading sales\u2026</p>}>
<SalesChart /> {/* async Server Component with a slow query */}
</Suspense>
</main>
);
}
Time to first byte, not time to everything
Streaming decouples first paint from your slowest data. Put your fast content outside Suspense and your slow widgets inside it. A `loading.tsx` file in a route segment is sugar for wrapping the whole page in a Suspense boundary.
Caching is explicit now (the 2026 reality)
Earlier App Router versions cached aggressively and implicitly, fetch was cached by default, and many teams got burned by stale data they never asked to cache. The 2026 direction reverses that default: caching is something you opt into, not something you fight.
Do not assume anything is cached
Treat data as uncached unless you explicitly mark it. Reach for `revalidatePath` / `revalidateTag` after mutations, set explicit revalidation windows where you *want* caching, and verify behavior rather than trusting an implicit default. The mental model is: fresh by default, fast by opt-in.
Layer
What it caches
How you control it
Request memoization
Duplicate fetches in one render
Automatic, per request
Data cache
fetch / data results across requests
Explicit revalidate window or tags
Full route cache
Rendered route output
revalidatePath, dynamic config
Router cache (client)
Visited segments in the browser
revalidate on navigation / refresh
The caching layers you actually reason about.
Common mistakes that cost hours
Putting `'use client'` too high in the tree. One directive at the top of a layout drags every child into the client bundle and kills the zero-JS benefit. Push it down to the smallest interactive leaf.
Fetching on the client when the server would do. Reaching for useEffect + fetch inside a Client Component to load data that a Server Component could await directly adds a network round-trip, a loading spinner, and a request waterfall for nothing.
Leaking secrets to the client. Importing a module that reads process.env.API_SECRET into a 'use client' file can bundle that value into the browser. Keep secrets and DB clients in server-only code; consider the server-only package to fail the build if they cross over.
Passing functions across the boundary. A closure prop from server to client throws a serialization error. Use a Server Action or move the handler into the client component.
Assuming data is cached (or that it is not). In 2026 the defaults shifted toward explicit caching, verify, do not guess.
Takeaways
The whole article in seven lines
Server Components render on the server and ship zero JS; Client Components hydrate and run in the browser.
`'use client'` is a boundary: it pulls that file and everything it imports into the browser bundle, keep it at the leaf.
Async Server Components `await` data directly, no useEffect, no loading state, no waterfall.
Server Actions (`'use server'`) handle mutations from forms, then `revalidatePath` to refresh the UI.
Suspense streams the shell first, so a slow query no longer blocks first paint.
Props crossing server → client must be serializable: no functions, no class instances (Server Actions are the exception).
Caching is explicit in 2026, fresh by default, fast by opt-in.
Where to go next
RSC sits on top of everything you already know about React and rendering. Solidify those foundations, then revisit this article, the boundary rules make far more sense once the underlying model is solid.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.