Next.js Server Components: Patterns for Production
Next.js Server Components represent a paradigm shift in how we build React applications. After migrating several production applications to the App Router, here are the patterns and practices that deliver real-world results.
Understanding Server Components
Server Components run exclusively on the server, fundamentally changing the React mental model and unlocking new capabilities.
Key Benefits
- Zero JavaScript Sent to Client: Server Components don't ship JavaScript
- Direct Backend Access: Query databases, read files, access APIs directly
- Improved Performance: Faster initial page loads, reduced bundle size
- Better SEO: Fully rendered HTML for search engines
- Automatic Code Splitting: Components are naturally split
Server vs Client Components
When to Use Server Components
✅ Use Server Components for:
- Data fetching from databases/APIs
- Accessing backend resources
- Sensitive logic (API keys, tokens)
- Large dependencies (markdown, syntax highlighting)
- Static content rendering
// Server Component - Direct database access
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
When to Use Client Components
✅ Use Client Components for:
- Event handlers (onClick, onChange)
- React hooks (useState, useEffect, useContext)
- Browser APIs (localStorage, window)
- Third-party libraries requiring browser APIs
- Interactive components
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Data Fetching Patterns
1. Component-Level Data Fetching
Fetch data where you need it:
// app/dashboard/page.tsx
async function Dashboard() {
// Parallel data fetching
const [user, stats, notifications] = await Promise.all([
fetchUser(),
fetchStats(),
fetchNotifications()
]);
return (
<div>
<UserHeader user={user} />
<StatsGrid stats={stats} />
<NotificationList notifications={notifications} />
</div>
);
}
2. Sequential Data Fetching
When data depends on previous results:
async function OrderDetails({ orderId }: { orderId: string }) {
// Fetch order first
const order = await fetchOrder(orderId);
// Then fetch related data
const customer = await fetchCustomer(order.customerId);
const items = await fetchOrderItems(orderId);
return (
<div>
<OrderHeader order={order} customer={customer} />
<OrderItems items={items} />
</div>
);
}
3. Streaming with Suspense
Improve perceived performance:
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Footer />
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // This can be slow
return <StatsGrid data={stats} />;
}
Composition Patterns
Server-Client Component Composition
// Server Component
async function ProductPage({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
{/* Server Component */}
<ProductHeader product={product} />
{/* Client Component for interactivity */}
<AddToCartButton productId={product.id} />
{/* Server Component */}
<ProductReviews productId={product.id} />
</div>
);
}
// Client Component
'use client';
function AddToCartButton({ productId }: { productId: string }) {
const [adding, setAdding] = useState(false);
const handleAdd = async () => {
setAdding(true);
await addToCart(productId);
setAdding(false);
};
return (
<button onClick={handleAdd} disabled={adding}>
{adding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Passing Server Components as Props
// Client Component accepting Server Components
'use client';
function Tabs({
children
}: {
children: React.ReactNode
}) {
const [active, setActive] = useState(0);
return (
<div>
<TabButtons active={active} onChange={setActive} />
<TabContent>{children[active]}</TabContent>
</div>
);
}
// Usage in Server Component
async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
]);
return (
<Tabs>
<UserList users={users} />
<PostList posts={posts} />
</Tabs>
);
}
Performance Optimization
1. Selective Hydration
Minimize client-side JavaScript:
// app/article/page.tsx
async function ArticlePage({ id }: { id: string }) {
const article = await fetchArticle(id);
return (
<div>
{/* Server Component - No JS */}
<ArticleHeader article={article} />
{/* Server Component - No JS */}
<ArticleContent content={article.content} />
{/* Client Component - Only this needs JS */}
<CommentSection articleId={id} />
{/* Server Component - No JS */}
<RelatedArticles articleId={id} />
</div>
);
}
2. Loading States
Implement proper loading UI:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-12 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
);
}
3. Error Boundaries
Handle errors gracefully:
// app/dashboard/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Metadata & SEO
Dynamic Metadata
import { Metadata } from 'next';
export async function generateMetadata({
params
}: {
params: { id: string }
}): Promise<Metadata> {
const product = await fetchProduct(params.id);
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
},
};
}
Static Params for SSG
export async function generateStaticParams() {
const products = await fetchAllProducts();
return products.map((product) => ({
id: product.id,
}));
}
Real-World Patterns
1. Dashboard with Real-Time Data
// Server Component for initial data
async function Dashboard() {
const initialData = await fetchDashboardData();
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardLayout>
{/* Server rendered */}
<StatsCards data={initialData.stats} />
{/* Client component for real-time updates */}
<LiveChart initialData={initialData.chartData} />
{/* Server rendered */}
<RecentActivity data={initialData.activity} />
</DashboardLayout>
</Suspense>
);
}
// Client Component for real-time
'use client';
function LiveChart({ initialData }) {
const [data, setData] = useState(initialData);
useEffect(() => {
const ws = new WebSocket('ws://api.example.com');
ws.onmessage = (event) => {
setData(JSON.parse(event.data));
};
return () => ws.close();
}, []);
return <Chart data={data} />;
}
2. Form with Server Actions
// Server Component
async function ContactForm() {
async function submitForm(formData: FormData) {
'use server';
const name = formData.get('name');
const email = formData.get('email');
await saveContact({ name, email });
revalidatePath('/contacts');
}
return (
<form action={submitForm}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
);
}
3. Authenticated Routes
import { redirect } from 'next/navigation';
async function ProtectedPage() {
const session = await getServerSession();
if (!session) {
redirect('/login');
}
const userData = await fetchUserData(session.userId);
return <UserDashboard data={userData} />;
}
Common Pitfalls & Solutions
❌ Pitfall 1: Prop Serialization
// ❌ Wrong - Can't pass functions or Date objects
<ClientComponent onSave={handleSave} date={new Date()} />
// ✅ Correct - Pass serializable data
<ClientComponent
onSave={() => handleSave()}
dateString={new Date().toISOString()}
/>
❌ Pitfall 2: Context in Server Components
// ❌ Wrong - Can't use Context in Server Components
export default function ServerComponent() {
const theme = useContext(ThemeContext); // Error!
}
// ✅ Correct - Use Client Component wrapper
'use client';
export function ThemeWrapper({ children }) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
❌ Pitfall 3: Importing Client-Only Libraries
// ❌ Wrong - Importing browser-only library in Server Component
import confetti from 'canvas-confetti';
// ✅ Correct - Use dynamic import in Client Component
'use client';
function Celebration() {
const celebrate = async () => {
const confetti = (await import('canvas-confetti')).default;
confetti();
};
return <button onClick={celebrate}>Celebrate!</button>;
}
Migration Strategy
Step-by-Step Migration
- Start with leaves: Migrate leaf components first
- Move up the tree: Gradually migrate parent components
- Mark boundaries: Use 'use client' strategically
- Test thoroughly: Ensure functionality and performance
- Monitor metrics: Track bundle size and performance
Conclusion
Server Components are not just an optimization—they're a new way of thinking about React applications. The key is understanding when to use Server vs Client Components and composing them effectively.
Key Takeaways:
- Server Components for data fetching and static content
- Client Components for interactivity and browser APIs
- Use Suspense for progressive loading
- Compose Server and Client Components thoughtfully
- Leverage Server Actions for mutations
Resources:
Ready to migrate? Start small, measure impact, and gradually adopt Server Components across your application.