The Paradigm Shift: React Server Components
Client Components vs. Server Components
| Server Components | Client Components | |
|---|---|---|
| Where they run | Server only | Server (initial render) + Browser |
| Ships JS to browser | No | Yes |
| Can use hooks (useState, useEffect) | No | Yes |
| Can access database/filesystem directly | Yes | No |
| Can use browser APIs (window, localStorage) | No | Yes |
| Re-renders on user interaction | No | Yes |
The Benefits of Server Components
- Zero Bundle Size Impact. Since Server Components don't ship JavaScript to the client, adding complex libraries (markdown parsers, date formatting tools, syntax highlighters, PDF generators) inside a Server Component won't increase your app's client-side bundle size at all. This is a big deal for any full-stack engineer trying to keep Lighthouse scores healthy without stripping features.
- Direct Backend Access. You can query your database or access the file system directly within the component. No more writing a separate API route purely to shuttle data into a component that lives two folders away.
- Automatic Code Splitting. Server Components automatically code-split your Client Components at their boundaries — you get granular chunking without manually configuring React.lazy everywhere.
- Security by Default. Sensitive data, API keys, and database credentials remain securely on the server and are never serialized into the client bundle, closing off an entire class of accidental data leaks.
- Faster Time-to-First-Byte and Streaming. Because Server Components can be rendered and streamed incrementally, users can see meaningful content before the entire page's data is ready — which matters both for perceived performance and for Core Web Vitals.
How Rendering Actually Works Under the Hood
- When a request hits a Next.js App Router route, the server renders the Server Component tree, executing any async data fetches along the way.
- The result isn't plain HTML alone — it's serialized into a special RSC payload, a compact format that describes the component tree, including "holes" where Client Components need to be hydrated.
- Client Components referenced inside that tree are compiled separately into their own JS bundles and sent alongside the payload.
- The browser uses the RSC payload to reconstruct the UI, hydrating only the Client Component boundaries — not the entire page.
- On subsequent navigations, Next.js can request just the RSC payload for the new route (not a full page reload), making client-side transitions fast even though most of the tree is server-rendered.
Jsx
// app/page.jsx — Server Component by default
import { Suspense } from 'react';
import Recommendations from './Recommendations';
export default async function HomePage() {
const featured = await getFeaturedProducts(); // runs on the server
return (
<main>
<Header />
<FeaturedGrid products={featured} />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations /> {/* streams in once its own data resolves */}
</Suspense>
</main>
);
}
When to Use Which?
- Use Server Components (the default): For fetching data, accessing backend resources, protecting sensitive information (API keys, tokens, database queries), and rendering static or mostly-static content. If a component doesn't need interactivity, it should be a Server Component.
- Use Client Components (add 'use client'): For interactivity and event listeners (onClick, onChange, onSubmit), for React hooks (useState, useEffect, useReducer, useContext), for browser-only APIs (window, document, localStorage, IntersectionObserver), and for third-party libraries that assume a browser environment.
The 'use client' Directive
Jsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
Interleaving Components
Jsx
// app/page.jsx (Server Component)
import InteractiveShell from './InteractiveShell';
import ProductDetails from './ProductDetails'; // also a Server Component
export default async function Page() {
return (
<InteractiveShell>
<ProductDetails /> {/* stays on the server, even though it's nested inside a client component */}
</InteractiveShell>
);
}
Jsx
// InteractiveShell.jsx
'use client';
export default function InteractiveShell({ children }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>Toggle</button>
{expanded && children}
</div>
);
}
Data Fetching Patterns
Jsx
async function ProductPage({ params }) {
const product = await db.product.findUnique({ where: { id: params.id } });
return <ProductView product={product} />;
}
- Request memoization. Next.js automatically deduplicates identical fetch calls made during the same render pass, so multiple components can call the same endpoint without triggering redundant network requests.
- Parallel data fetching. Kick off independent fetches together rather than sequentially awaiting each one:
Jsx
async function Page() {
const productsPromise = getProducts();
const reviewsPromise = getReviews();
const [products, reviews] = await Promise.all([productsPromise, reviewsPromise]);
return <ProductWithReviews products={products} reviews={reviews} />;
}
- Streaming with granular Suspense boundaries so slow data sources don't block fast ones from rendering.
Server Actions
Jsx
// actions.js
'use server';
export async function addToCart(productId) {
await db.cart.insert({ productId });
}
Jsx
'use client';
import { addToCart } from './actions';
export default function AddToCartButton({ productId }) {
return <button onClick={() => addToCart(productId)}>Add to Cart</button>;
}
Common Pitfalls
- Serialization limits. Props passed from a Server Component to a Client Component must be serializable — functions, class instances, and Dates need special handling (Dates serialize fine, but you can't pass a database client or a function reference across the boundary).
- Importing server-only code into a Client Component. Importing a module that touches fs, environment secrets, or a database client from inside a 'use client' file will either fail the build or silently leak server code into the client bundle — keep server-only utilities in files that are never imported by client boundaries.
- Marking too much as 'use client'. As mentioned above, this is the single most common way teams accidentally ship a bundle just as large as before RSC, negating most of the benefit.
- Fetching in useEffect inside a Client Component when a Server Component would do. If the data isn't tied to user interaction or client-only state, it usually belongs in a Server Component higher up the tree.
- Forgetting Suspense boundaries. Without them, a single slow data source can block the entire page from rendering, even though RSC is designed to stream.
Performance and SEO Benefits
- Faster First Contentful Paint and Time to Interactive, since less JavaScript needs to download, parse, and hydrate before the page is usable.
- Search engines see fully rendered HTML on the initial response, the same way they would with traditional server-side rendering — there's no reliance on client-side JavaScript execution for crawlers to index content.
- Smaller client bundles improve Core Web Vitals, particularly Total Blocking Time and Interaction to Next Paint, which are increasingly weighted in search ranking signals.
- Streaming means faster perceived load, which correlates with lower bounce rates — a metric search engines and analytics platforms both care about.
Migrating from the Pages Router or Traditional CSR
- Start with new routes in the App Router, leaving existing Pages Router routes untouched — the two can coexist during migration.
- Identify data-fetching components (anything currently using useEffect + fetch purely to load initial data) as your first conversion candidates — these map cleanly onto async Server Components.
- Isolate interactivity into small, focused Client Components rather than converting entire pages wholesale.
- Move mutations to Server Actions incrementally, replacing hand-rolled API routes where it makes sense.
- Audit bundle size before and after each migrated route to confirm you're actually seeing the JS reduction RSC promises — it's easy to accidentally keep a 'use client' boundary too high and get none of the benefit.
Best Practices Checklist
- Default to Server Components; add 'use client' only where interactivity or browser APIs are required.
- Push client boundaries as far down the component tree as possible.
- Keep server-only logic (database clients, secrets) out of any file that a Client Component might import.
- Use Suspense boundaries around slow or independent data sources so fast content isn't blocked.
- Prefer Server Actions over hand-written API routes for simple mutations tied directly to UI.
- Regularly audit your client bundle to catch accidental leakage of server-only code or oversized client boundaries.