Hero image for Next.js App Router: When Server Components Actually Make Sense

Next.js App Router: When Server Components Actually Make Sense


You’ve migrated to the App Router, but now every component decision feels like a coin flip. Should this form be a Server Component? What about that dropdown menu? You read the docs, followed the “use server by default” advice, and somehow your bundle is still massive. Worse, you’re hitting hydration errors and wrestling with prop serialization in places that worked fine with the Pages Router.

The problem isn’t the App Router itself—it’s that the mental model we’ve built over years of client-side React doesn’t translate directly to this new paradigm. Server Components aren’t just “components that run on the server.” They represent a fundamental shift in how we think about data flow, interactivity, and the boundary between server and client code. This shift challenges assumptions that have been deeply ingrained in the React ecosystem since its inception.

For years, React developers have optimized for a single environment: the browser. We’ve learned to minimize re-renders, memoize expensive calculations, and split bundles to reduce load times. These skills remain valuable, but they’re now part of a larger picture that includes server-side rendering, streaming, and hybrid architectures that blur the line between static and dynamic content.

In this guide, we’ll build a practical decision framework for choosing between Server and Client Components. No abstract theory—just real patterns from production applications, complete with the edge cases that the documentation glosses over. By the end, you’ll have a reliable mental model for making component decisions quickly and confidently.

Understanding the Core Mental Model

Before diving into specific patterns, we need to establish what Server Components actually are and why they exist. This isn’t about memorizing rules; it’s about understanding the underlying model so you can reason about new situations. Once you grasp the fundamentals, the specific patterns and solutions will feel intuitive rather than arbitrary.

What Server Components Actually Do

Server Components render entirely on the server. Their JavaScript never ships to the browser. This sounds simple, but the implications are profound and far-reaching:

app/products/page.tsx
// This is a Server Component by default
import { db } from '@/lib/database';
import { ProductCard } from './product-card';
export default async function ProductsPage() {
// This database query runs on the server
// The db module is never bundled for the client
const products = await db.products.findMany({
include: { category: true, reviews: true }
});
// We can use server-only libraries freely
// This heavy analytics library adds zero bytes to client bundle
const analytics = await import('heavy-analytics-lib');
analytics.trackPageView('products');
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

In this example, the database client, the analytics library, and all the processing logic stay on the server. The client receives only the rendered HTML and whatever Client Components are needed for interactivity. This is fundamentally different from traditional server-side rendering, where the same code that renders on the server is also shipped to the client for hydration.

The performance implications are significant. Consider a typical e-commerce product page. With traditional client-side rendering, you might ship hundreds of kilobytes of JavaScript: React itself, your component library, data fetching utilities, state management, and the actual component code. With Server Components, much of this can stay on the server. The database ORM, markdown parsers, image processing libraries—none of these need to be downloaded by the user’s browser.

But Server Components aren’t just about bundle size. They also simplify data fetching by eliminating the waterfall problem common in client-side applications. Instead of rendering a loading skeleton, fetching data, rendering another loading skeleton for nested data, and so on, Server Components can fetch all required data in a single server round-trip before sending anything to the client.

The Serialization Boundary

Here’s where most developers trip up: when you pass props from a Server Component to a Client Component, those props must be serializable to JSON. This means no functions, no class instances, no Dates (they become strings), and no circular references. Understanding this boundary is crucial because it affects how you structure your entire component hierarchy.

app/dashboard/page.tsx
// This will fail at runtime
import { UserProfile } from './user-profile';
export default async function DashboardPage() {
const user = await getUser();
return (
<UserProfile
user={user}
// ERROR: Functions cannot be passed to Client Components
// The serialization boundary prevents this
onLogout={() => signOut()}
// ERROR: Date objects are serialized to strings
// user.lastLoginAt instanceof Date === true on server
// but on the client, it becomes a string
lastLogin={user.lastLoginAt}
/>
);
}

The fix requires rethinking the component boundary. Instead of trying to pass everything through props, we need to structure our components so that interactive logic lives where it can be executed:

app/dashboard/page.tsx
import { UserProfile } from './user-profile';
import { LogoutButton } from './logout-button';
export default async function DashboardPage() {
const user = await getUser();
return (
<div>
<UserProfile
user={{
...user,
// Convert Date to ISO string explicitly
// The Client Component can parse it back if needed
lastLoginAt: user.lastLoginAt.toISOString()
}}
/>
{/* Move interactive logic to a dedicated Client Component */}
{/* The logout action itself can be a Server Action */}
<LogoutButton />
</div>
);
}

This pattern—explicitly converting data at the boundary—might feel tedious at first, but it makes the data flow through your application explicit and predictable. You always know exactly what data is crossing the server-client boundary, which makes debugging significantly easier.

Why the Boundary Exists

The serialization boundary isn’t an arbitrary limitation—it reflects a fundamental truth about distributed systems. The server and client are separate environments that communicate over a network. Functions contain closures that reference variables in their scope; these closures can’t be transmitted over HTTP. Class instances have prototype chains and methods that don’t serialize to JSON. The boundary forces you to think about what data actually needs to cross the network, which often leads to cleaner architectures.

The Decision Framework

After building dozens of Next.js applications with the App Router, I’ve developed a three-question framework for every component decision. These questions aren’t arbitrary—they map directly to the technical constraints and capabilities of each component type:

  1. Does it need browser APIs or event handlers?
  2. Does it need React state or effects?
  3. Does it benefit from staying on the server?

If you answer “yes” to questions 1 or 2, you need a Client Component. If you answer “yes” to question 3 and “no” to 1 and 2, keep it as a Server Component. Let’s examine each question in detail, including the nuanced edge cases that often trip up developers.

Question 1: Browser APIs and Event Handlers

Any component that uses window, document, localStorage, navigator, or other browser-only APIs must be a Client Component. Similarly, any component with onClick, onChange, onSubmit, or other event handlers needs to be a Client Component. This is non-negotiable because these APIs simply don’t exist in Node.js.

components/theme-toggle.tsx
'use client';
import { useState, useEffect } from 'react';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Browser API: localStorage
// This code only runs in the browser after hydration
const saved = localStorage.getItem('theme') as 'light' | 'dark';
if (saved) setTheme(saved);
// We could also check system preference
// Browser API: window.matchMedia
if (!saved) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDark ? 'dark' : 'light');
}
}, []);
const toggle = () => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
// Browser API: localStorage
localStorage.setItem('theme', next);
// Browser API: document
document.documentElement.classList.toggle('dark');
};
// Event handler: onClick
return (
<button onClick={toggle} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}

This is a clear-cut case. There’s no way around it—this component must be a Client Component. However, notice that we’re using useEffect for the initial theme detection. This is intentional: we want to avoid hydration mismatches by not trying to read localStorage during the initial server render.

Question 2: React State and Effects

Components using useState, useReducer, useEffect, useLayoutEffect, useRef (with mutations), or any custom hooks that use these internally require the 'use client' directive. These hooks rely on React’s client-side runtime to manage component lifecycle and state.

But here’s where it gets nuanced: not every component that could use state should use state. One of the most valuable skills in the App Router era is recognizing when client-side state is unnecessary.

Consider a search input. Your instinct might be to reach for useState:

// DON'T: Unnecessary client-side state
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function SearchInput() {
const [query, setQuery] = useState('');
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}

But you can achieve the same result with a Server Action and no client-side state:

// DO: Server-side form handling
// components/search-input.tsx (Server Component)
import { redirect } from 'next/navigation';
export function SearchInput() {
async function search(formData: FormData) {
'use server';
const query = formData.get('q') as string;
redirect(`/search?q=${encodeURIComponent(query)}`);
}
return (
<form action={search}>
<input name="q" placeholder="Search..." />
<button type="submit">Search</button>
</form>
);
}

The second version ships zero JavaScript to the client for this component. The form works with or without JavaScript enabled, making it more resilient and accessible. This is progressive enhancement in action—the baseline functionality works for everyone, and JavaScript can layer on additional features if available.

This doesn’t mean you should never use controlled inputs. If you need instant validation feedback, character counters, or auto-suggestions as the user types, you’ll need client-side state. The key is recognizing when those features are actually required versus when they’re reflexive patterns from the single-page app era.

Question 3: Server Benefits

The third question asks whether keeping the component on the server provides meaningful benefits. Server Components shine when:

  • Fetching data: Direct database access, no API layer needed, no client-side waterfall
  • Using large dependencies: Heavy libraries stay off the client bundle entirely
  • Protecting secrets: API keys, database credentials, and tokens never leave the server
  • Rendering static content: Content that doesn’t change per-user can be cached aggressively
  • Processing data: Transformations, filtering, and aggregations happen on powerful server hardware
app/blog/[slug]/page.tsx
import { marked } from 'marked'; // 50KB library, never shipped to client
import { highlight } from 'highlight.js'; // 1MB+ library, server-only
import { db } from '@/lib/database';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: { author: true }
});
if (!post) notFound();
// Heavy processing happens on the server
// marked and highlight.js never touch the client
const htmlContent = marked(post.content, {
highlight: (code, lang) => {
try {
return highlight(code, { language: lang }).value;
} catch {
return code; // Fallback for unknown languages
}
}
});
return (
<article>
<h1>{post.title}</h1>
<p className="text-gray-600">By {post.author.name}</p>
<div
className="prose"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</article>
);
}

In this blog post component, we’re using marked and highlight.js—two libraries that together could add over a megabyte to your client bundle. As a Server Component, they’re never shipped to the browser. The user receives only the rendered HTML, with all syntax highlighting already applied.

Common Patterns and Their Solutions

Let’s walk through the patterns you’ll encounter most frequently, with production-ready solutions that handle real-world complexity.

Pattern 1: Data Fetching with Interactive Elements

You have a list of items fetched from a database, and each item has interactive buttons. The naive approach is to make the entire component a Client Component:

// DON'T: Everything is a Client Component
// This ships unnecessary JavaScript and creates client-side waterfalls
'use client';
import { useState, useEffect } from 'react';
export function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
<button onClick={() => addToCart(product.id)}>
Add to Cart
</button>
</li>
))}
</ul>
);
}

This approach has multiple problems. First, users see a loading state while data is fetched. Second, you’ve shipped JavaScript for rendering the list itself, not just the interactive button. Third, search engines may not index the content properly.

The better approach uses composition—Server Component for data, Client Component for interactivity:

// app/products/page.tsx (Server Component)
import { db } from '@/lib/database';
import { AddToCartButton } from './add-to-cart-button';
export default async function ProductsPage() {
// Direct database access - no API layer needed
const products = await db.products.findMany({
select: {
id: true,
name: true,
price: true,
imageUrl: true
}
});
return (
<ul className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map(product => (
<li key={product.id} className="border rounded-lg p-4">
<img src={product.imageUrl} alt={product.name} />
<h2 className="font-bold">{product.name}</h2>
<p className="text-gray-600">${product.price.toFixed(2)}</p>
{/* Only the button is a Client Component */}
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
);
}
// components/add-to-cart-button.tsx
'use client';
import { useState } from 'react';
import { addToCart } from '@/actions/cart';
export function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button
onClick={handleClick}
disabled={isAdding}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}

The product list renders on the server with direct database access. Users see content immediately without a loading state. Only the small button component ships to the client, and it’s reused for each product.

Pattern 2: Forms with Validation

Forms are tricky because they often need both client-side validation (for immediate UX feedback) and server-side validation (for security and data integrity). Here’s how to structure them to get the best of both worlds:

components/contact-form.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { submitContact } from '@/actions/contact';
// Separate component for submit button to use useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
>
{pending ? 'Sending...' : 'Send Message'}
</button>
);
}
export function ContactForm() {
const [state, formAction] = useFormState(submitContact, {
message: '',
errors: {}
});
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded px-3 py-2"
aria-describedby={state.errors?.email ? 'email-error' : undefined}
/>
{state.errors?.email && (
<p id="email-error" className="text-red-500 text-sm mt-1">
{state.errors.email}
</p>
)}
</div>
<div>
<label htmlFor="message" className="block font-medium">
Message
</label>
<textarea
id="message"
name="message"
required
minLength={10}
rows={5}
className="w-full border rounded px-3 py-2"
aria-describedby={state.errors?.message ? 'message-error' : undefined}
/>
{state.errors?.message && (
<p id="message-error" className="text-red-500 text-sm mt-1">
{state.errors.message}
</p>
)}
</div>
<SubmitButton />
{state.message && (
<p className="text-green-500 font-medium">{state.message}</p>
)}
</form>
);
}
actions/contact.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/database';
const schema = z.object({
email: z.string().email('Please enter a valid email address'),
message: z.string()
.min(10, 'Message must be at least 10 characters')
.max(1000, 'Message cannot exceed 1000 characters')
});
type FormState = {
message: string;
errors: Record<string, string>;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Server-side validation with Zod
const result = schema.safeParse({
email: formData.get('email'),
message: formData.get('message')
});
if (!result.success) {
// Transform Zod errors into a simple object
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const path = issue.path[0] as string;
errors[path] = issue.message;
}
return { message: '', errors };
}
// Save to database
await db.contacts.create({
data: {
email: result.data.email,
message: result.data.message,
createdAt: new Date()
}
});
return {
message: 'Thanks for reaching out! We\'ll get back to you soon.',
errors: {}
};
}

This pattern gives you the best of both worlds: HTML5 validation attributes (required, type="email", minLength) for instant browser feedback, useFormStatus for pending states during submission, and server-side validation with Zod for security. The form remains functional even if JavaScript fails to load.

Pattern 3: Authentication-Aware Components

Components that show different content based on authentication status are common. The key insight is that authentication checks can happen on the server, avoiding the flash of unauthenticated content that plagues client-side auth checks:

// app/header.tsx (Server Component)
import { getSession } from '@/lib/auth';
import { UserMenu } from './user-menu';
import { LoginButton } from './login-button';
export async function Header() {
// Auth check happens on the server
// No loading state, no flash of wrong content
const session = await getSession();
return (
<header className="flex items-center justify-between p-4 border-b">
<nav className="flex gap-4">
<a href="/" className="font-bold">Home</a>
<a href="/products">Products</a>
{session && <a href="/dashboard">Dashboard</a>}
</nav>
{session ? (
// UserMenu is a Client Component for dropdown interactivity
<UserMenu user={session.user} />
) : (
// LoginButton might just be a link, no JS needed
<LoginButton />
)}
</header>
);
}

The getSession() call happens on the server, so the authentication check doesn’t require client-side JavaScript. Users never see a loading skeleton or a flash of the wrong state. The UserMenu might be a Client Component (for dropdown behavior), but the authentication logic itself stays on the server where it’s more secure.

Pattern 4: Modals and Dialogs

Modals need to manage open/closed state, trap focus, and handle keyboard events. They’re inherently client-side, but you can still optimize how much JavaScript they require. A key technique is using the native <dialog> element, which handles many accessibility concerns automatically:

components/modal.tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
// Handle native dialog close event (Escape key, backdrop click)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => onClose();
dialog.addEventListener('close', handleClose);
return () => dialog.removeEventListener('close', handleClose);
}, [onClose]);
// Handle backdrop click to close
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === dialogRef.current) {
onClose();
}
}, [onClose]);
// Don't render on server
if (typeof window === 'undefined') return null;
return createPortal(
<dialog
ref={dialogRef}
className="backdrop:bg-black/50 rounded-lg p-0 max-w-md w-full"
onClick={handleBackdropClick}
>
<div className="p-6">
<header className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="text-gray-500 hover:text-gray-700"
>
</button>
</header>
{children}
</div>
</dialog>,
document.body
);
}

The modal component is a Client Component, but notice that the children can be Server Components. This is the “donut” pattern—a Client Component wrapper with a Server Component hole in the middle. The modal provides the interactive shell, while the content inside can be rendered on the server.

Pattern 5: Infinite Scroll and Pagination

Infinite scroll requires client-side state to track loaded items and intersection observers to trigger loading. However, the initial page load can still be a Server Component, giving you the best initial load performance while enabling dynamic loading afterward:

// app/feed/page.tsx (Server Component)
import { db } from '@/lib/database';
import { FeedList } from './feed-list';
export default async function FeedPage() {
// Initial data fetched on the server - fast, no waterfall
const initialPosts = await db.posts.findMany({
take: 20,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true, avatar: true } } }
});
return (
<main className="max-w-2xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Your Feed</h1>
<FeedList
initialPosts={initialPosts.map(post => ({
...post,
createdAt: post.createdAt.toISOString() // Serialize Date
}))}
initialCursor={initialPosts[initialPosts.length - 1]?.id}
/>
</main>
);
}
// components/feed-list.tsx
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { loadMorePosts } from '@/actions/posts';
interface Post {
id: string;
title: string;
content: string;
createdAt: string;
author: { name: string; avatar: string };
}
interface FeedListProps {
initialPosts: Post[];
initialCursor?: string;
}
export function FeedList({ initialPosts, initialCursor }: FeedListProps) {
const [posts, setPosts] = useState(initialPosts);
const [cursor, setCursor] = useState(initialCursor);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
if (!cursor || loading || !hasMore) return;
setLoading(true);
const result = await loadMorePosts(cursor);
if (result.posts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...result.posts]);
setCursor(result.nextCursor);
}
setLoading(false);
}, [cursor, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1, rootMargin: '100px' }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [loadMore]);
return (
<div className="space-y-6">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<img
src={post.author.avatar}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="font-medium">{post.author.name}</span>
</div>
<h2 className="text-lg font-bold">{post.title}</h2>
<p className="text-gray-600">{post.content}</p>
</article>
))}
{/* Intersection observer target */}
<div ref={observerRef} className="h-10" />
{loading && (
<p className="text-center text-gray-500">Loading more posts...</p>
)}
{!hasMore && (
<p className="text-center text-gray-500">You've reached the end!</p>
)}
</div>
);
}

The first 20 posts render on the server for fast initial load and good SEO. Subsequent posts load on the client as the user scrolls, providing a smooth infinite scroll experience.

Debugging Common Issues

Even with a solid framework, you’ll encounter issues. Here are the most common problems and their solutions, along with strategies for preventing them in the future.

Hydration Mismatches

Hydration mismatches occur when the server-rendered HTML doesn’t match what the client expects to render. React will show a warning in development and may discard the server-rendered HTML entirely, negating the performance benefits of server rendering.

Common causes include:

  1. Using browser-only values during render: window.innerWidth, Date.now(), Math.random()
  2. Conditional rendering based on client state: Checking localStorage during initial render
  3. Date formatting differences: Server and client in different timezones
  4. Browser extensions: Extensions that modify the DOM before React hydrates
// DON'T: This causes hydration mismatch
'use client';
export function Greeting() {
// Different on server vs client because time zones may differ
// or the render might happen at different times
const hour = new Date().getHours();
return <p>{hour < 12 ? 'Good morning' : 'Good afternoon'}</p>;
}
// DO: Defer to client-side effect
'use client';
import { useState, useEffect } from 'react';
export function Greeting() {
// Start with a neutral value that matches server render
const [greeting, setGreeting] = useState('Hello');
useEffect(() => {
// This only runs on the client after hydration
const hour = new Date().getHours();
setGreeting(hour < 12 ? 'Good morning' : 'Good afternoon');
}, []);
return <p>{greeting}</p>;
}

”Functions cannot be passed as props” Error

This error means you’re trying to pass a function from a Server Component to a Client Component. The solution depends on what the function does:

If it’s a click handler, move it to the Client Component:

// Server Component
import { DeleteButton } from './delete-button';
export function Item({ id }: { id: string }) {
// Just pass the data needed to construct the action
return <DeleteButton itemId={id} />;
}
// Client Component
'use client';
import { deleteItem } from '@/actions/items';
export function DeleteButton({ itemId }: { itemId: string }) {
return (
<button onClick={() => deleteItem(itemId)}>
Delete
</button>
);
}

If it’s a Server Action, you can pass it directly—Server Actions are an exception to the serialization rule because they’re actually serialized as references, not as code:

// Server Component
import { revalidatePath } from 'next/cache';
export function RefreshButton() {
async function refresh() {
'use server';
revalidatePath('/dashboard');
}
// This works! Server Actions can be passed as form actions
return (
<form action={refresh}>
<button type="submit">Refresh</button>
</form>
);
}

Bundle Size Still Large

If your bundle is larger than expected, you might be accidentally importing server-only code into Client Components. Use the server-only package to catch this at build time:

lib/database.ts
import 'server-only';
import { PrismaClient } from '@prisma/client';
// If this file is imported in a Client Component,
// the build will fail with a clear error message
export const db = new PrismaClient();

Now if you accidentally import db in a Client Component, you’ll get a build error instead of silently bundling Prisma for the client. This is a defensive programming technique that saves hours of debugging bundle size issues.

You can also use the Next.js bundle analyzer to visualize what’s in your client bundle:

Terminal window
npm install @next/bundle-analyzer

Performance Optimization Strategies

Beyond the basic framework, here are advanced strategies for optimizing Server and Client Component usage in production applications.

Streaming and Suspense

Server Components support streaming, allowing parts of the page to render progressively. Wrap slow components in Suspense boundaries to show fast content immediately while slow content loads:

app/dashboard/page.tsx
import { Suspense } from 'react';
import { QuickStats } from './quick-stats';
import { SlowAnalytics } from './slow-analytics';
import { RecentActivity } from './recent-activity';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
{/* Fast component renders immediately */}
<QuickStats />
{/* Slow components stream in as they complete */}
<Suspense fallback={<AnalyticsSkeleton />}>
<SlowAnalytics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}

Each Suspense boundary streams independently, so users see content as soon as it’s ready rather than waiting for the slowest component. This dramatically improves perceived performance.

Partial Prerendering

Next.js supports partial prerendering, combining static and dynamic content in a single page. Static parts are cached at the edge and served instantly, while dynamic parts stream in:

app/product/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './product-details';
import { DynamicPricing } from './dynamic-pricing';
import { UserReviews } from './user-reviews';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div className="max-w-4xl mx-auto">
{/* Static: Product details cached at build time */}
<ProductDetails id={params.id} />
{/* Dynamic: Price might vary by user location, promotions, etc. */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPricing productId={params.id} />
</Suspense>
{/* Dynamic: Reviews update frequently */}
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviews productId={params.id} />
</Suspense>
</div>
);
}

Lazy Loading Client Components

For Client Components that aren’t immediately needed, use dynamic imports to defer loading:

app/page.tsx
import dynamic from 'next/dynamic';
// Heavy chart library only loads when the component is rendered
const AnalyticsChart = dynamic(
() => import('./analytics-chart'),
{
loading: () => <ChartSkeleton />,
ssr: false // Skip server rendering for client-only components
}
);
// Comments section loads lazily as user scrolls
const Comments = dynamic(
() => import('./comments'),
{ loading: () => <CommentsSkeleton /> }
);
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<AnalyticsChart />
<Comments />
</div>
);
}

Migration Strategies

If you’re migrating from the Pages Router, here’s a practical approach that minimizes risk:

  1. Start with layouts: Convert _app.tsx and _document.tsx to layout.tsx. This is the least risky change and sets up the foundation.

  2. Keep pages as Client Components initially: Add 'use client' to maintain existing behavior. Everything should work exactly as before.

  3. Identify server-first opportunities: Look for pages that fetch data in getServerSideProps or getStaticProps. These are prime candidates for conversion.

  4. Extract interactive elements: Gradually move interactive pieces to dedicated Client Components. Start with clearly interactive elements like buttons and forms.

  5. Convert remaining pages: Once interactive elements are extracted, remove 'use client' from page components and let them become Server Components.

This incremental approach lets you ship improvements continuously rather than doing a risky big-bang migration. Each step provides immediate value while reducing the risk of breaking changes.

Key Takeaways

Server Components and Client Components aren’t competing approaches—they’re complementary tools for different jobs. Here’s what to remember:

  1. Default to Server Components: Start with Server Components and only add 'use client' when you need browser APIs, event handlers, or React state. This approach minimizes bundle size and maximizes performance by default.

  2. Use the composition pattern: Server Components can render Client Components, and Client Components can render Server Component children through props. This “donut” pattern is powerful for creating interactive wrappers around server-rendered content.

  3. Think in boundaries: The serialization boundary between Server and Client Components is where most bugs occur. Design your components with this boundary in mind, and explicitly handle data conversion at the boundary.

  4. Server Actions bridge the gap: Server Actions let Client Components trigger server-side logic without building API routes. They’re the glue between the two worlds and enable forms that work without JavaScript.

  5. Profile your bundles: Use Next.js’s built-in bundle analyzer to verify that heavy libraries aren’t sneaking into your client bundle. The server-only package can catch accidental imports at build time.

  6. Progressive enhancement still matters: Forms with Server Actions work without JavaScript. This isn’t just about accessibility—it makes your applications more resilient to network issues and JavaScript failures.

  7. Stream what you can: Suspense boundaries and streaming let you show content progressively. Don’t make users wait for the slowest component when faster components are ready.

The App Router represents React’s vision for the future—a world where server and client code coexist seamlessly, each running where it makes the most sense. The learning curve is real, but once the mental model clicks, you’ll find yourself building faster, more efficient applications with less complexity than ever before.

The next time you reach for useState or add 'use client', pause and ask: “Does this actually need to run on the client?” More often than not, the answer will surprise you.