React Server Components have been stable in Next.js since version 14, yet most developers are still sprinkling "use client" across their codebase without a clear strategy. The result is bloated client bundles, duplicated data fetching logic, and components that are harder to reason about than they need to be.
The core idea behind Server Components is deceptively simple: render what you can on the server, and send only the interactive parts to the browser. But the real skill is knowing where to draw that line. After working with Server Components in production across multiple Next.js projects, seven patterns have emerged that cover the vast majority of real-world decisions.
This guide walks through each pattern with concrete code examples so you can apply them immediately.
1. The Data Boundary Pattern
The most impactful pattern is also the most straightforward: fetch data in Server Components and pass it down as props to Client Components. This eliminates the need for useEffect data fetching, loading spinners for initial data, and client-side state management libraries for server-derived data.
async function DashboardPage() {
const portfolio = await getPortfolio()
const positions = await getOpenPositions()
return (
<div>
<PortfolioSummary data={portfolio} />
<PositionTable positions={positions} />
<TradePanel symbols={positions.map(p => p.symbol)} />
</div>
)
}
Here, DashboardPage is a Server Component. It fetches data directly using async/await with no hooks, no loading states, and no client-side cache invalidation. The data arrives fully resolved before the HTML reaches the browser.
PortfolioSummary and PositionTable can remain Server Components if they only display data. Only TradePanel, which handles user interactions like placing orders, needs "use client".
The key insight: data flows downward from Server Components to Client Components through props. Never fetch on the client what you can fetch on the server.
2. The Island Pattern
Not every interactive element needs to make its parent a Client Component. The Island Pattern keeps the majority of your page server-rendered and wraps only the smallest possible interactive regions in Client Components.
async function IndicatorPage({ params }: { params: { slug: string } }) {
const indicator = await getIndicator(params.slug)
const history = await getIndicatorHistory(indicator.id)
return (
<article>
<h1>{indicator.name}</h1>
<p>{indicator.description}</p>
<SignalHistoryTable data={history} />
<InteractiveChart
data={history}
defaultTimeframe="1d"
/>
<SubscribeButton indicatorId={indicator.id} />
<section>
<h2>How This Indicator Works</h2>
<div dangerouslySetInnerHTML={{ __html: indicator.explanation }} />
</section>
</article>
)
}
In this layout, only InteractiveChart (which handles zoom, pan, timeframe switching) and SubscribeButton (which handles click events and API calls) are Client Components. Everything else -- the heading, description, signal history table, and explanation section -- renders on the server with zero JavaScript sent to the browser.
The mistake developers make is adding "use client" to the entire page because one small piece needs interactivity. Resist that impulse. Extract the interactive piece into its own component.
3. The Composition Pattern (Children as a Slot)
This is the pattern that trips up most developers. A Client Component can render Server Component children by accepting them through the children prop or any other prop that takes ReactNode.
"use client"
import { useState } from "react"
function Tabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div>
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.label}
role="tab"
aria-selected={i === activeIndex}
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">
{tabs[activeIndex].content}
</div>
</div>
)
}
Now the parent Server Component can pass server-rendered content into each tab:
async function AnalyticsPage() {
const performance = await getPerformanceData()
const signals = await getRecentSignals()
return (
<Tabs
tabs={[
{
label: "Performance",
content: <PerformanceBreakdown data={performance} />,
},
{
label: "Signals",
content: <SignalFeed signals={signals} />,
},
]}
/>
)
}
Tabs is a Client Component that manages which tab is visible. But PerformanceBreakdown and SignalFeed are Server Components that were pre-rendered on the server. The Client Component receives their already-rendered output and simply shows or hides it. No additional JavaScript is shipped for those components.
This pattern is essential for building interactive layouts (tabs, accordions, modals, drawers) without forcing the content inside them to become client-side.
4. The Streaming Pattern with Suspense
When a Server Component needs to fetch slow data, you do not need to block the entire page. Wrap the slow component in Suspense to stream it in after the rest of the page has loaded.
import { Suspense } from "react"
async function TradingDashboard() {
return (
<div>
<h1>Dashboard</h1>
<QuickStats />
<Suspense fallback={<PredictionsSkeleton />}>
<LatestPredictions />
</Suspense>
<Suspense fallback={<LeaderboardSkeleton />}>
<TopPerformers />
</Suspense>
</div>
)
}
async function LatestPredictions() {
const predictions = await getPredictions()
return <PredictionGrid predictions={predictions} />
}
async function TopPerformers() {
const performers = await getTopPerformers()
return <LeaderboardTable performers={performers} />
}
The page shell, heading, and QuickStats render immediately. LatestPredictions and TopPerformers stream in independently as their data resolves. Each shows a skeleton placeholder until ready.
This is far superior to the old pattern of fetching everything client-side with useEffect and managing multiple loading states. The server handles the orchestration, and the browser progressively reveals content as it arrives.
5. The Server Action Pattern
Server Actions let Client Components call server-side functions directly without building API routes. This eliminates an entire layer of boilerplate for mutations.
async function updateWatchlist(formData: FormData) {
"use server"
const symbol = formData.get("symbol") as string
const action = formData.get("action") as string
if (action === "add") {
await db.watchlist.create({ data: { symbol, userId: await getCurrentUserId() } })
} else {
await db.watchlist.delete({
where: { symbol_userId: { symbol, userId: await getCurrentUserId() } },
})
}
revalidatePath("/watchlist")
}
The Client Component calls this directly:
"use client"
import { useTransition } from "react"
function WatchlistButton({ symbol, isWatched }: { symbol: string; isWatched: boolean }) {
const [isPending, startTransition] = useTransition()
return (
<form action={(formData) => startTransition(() => updateWatchlist(formData))}>
<input type="hidden" name="symbol" value={symbol} />
<input type="hidden" name="action" value={isWatched ? "remove" : "add"} />
<button type="submit" disabled={isPending}>
{isPending ? "Updating..." : isWatched ? "Remove" : "Watch"}
</button>
</form>
)
}
No API route. No fetch call. No manual cache invalidation. The Server Action runs on the server, mutates the database, revalidates the cache, and the UI updates automatically.
6. The Conditional Client Pattern
Some components need interactivity only under certain conditions. Instead of making the entire component a Client Component, split it into a Server Component wrapper that conditionally renders a Client Component.
async function PriceDisplay({ symbol }: { symbol: string }) {
const price = await getCurrentPrice(symbol)
const user = await getCurrentUser()
if (user?.preferences.liveUpdates) {
return <LivePriceTicker symbol={symbol} initialPrice={price} />
}
return (
<div>
<span>{price.toFixed(2)}</span>
<span>USD</span>
</div>
)
}
Users without live updates enabled get a zero-JavaScript static price display. Users who opted in get the LivePriceTicker Client Component with WebSocket connections. The decision is made on the server, so no unnecessary code is shipped to users who do not need it.
7. The Parallel Data Pattern
Server Components unlock a data fetching pattern that is awkward to achieve with client-side useEffect: parallel fetching without waterfalls.
async function MarketOverview() {
const [btcData, ethData, topMovers, marketSentiment] = await Promise.all([
getTickerData("BTCUSDT"),
getTickerData("ETHUSDT"),
getTopMovers(10),
getMarketSentiment(),
])
return (
<div>
<TickerCard data={btcData} />
<TickerCard data={ethData} />
<TopMoversTable movers={topMovers} />
<SentimentGauge value={marketSentiment} />
</div>
)
}
All four data fetches execute simultaneously on the server. On the client, achieving this with useEffect typically creates waterfalls where each hook fires independently, or requires a complex orchestration library. On the server, it is a single Promise.all.
The Decision Framework
When deciding whether a component should be a Server or Client Component, work through this checklist:
| Question | If Yes | If No |
|----------|--------|-------|
| Does it use useState, useEffect, or other hooks? | Client | Server |
| Does it attach event handlers (onClick, onChange)? | Client | Server |
| Does it use browser-only APIs (window, localStorage)? | Client | Server |
| Does it need to fetch data? | Prefer Server | Either |
| Does it render static or read-only content? | Server | Either |
| Is it a leaf node with no children? | Either (default Server) | Either |
The default should always be Server Component. Only add "use client" when you have a specific reason from the list above. If you find yourself adding it "just in case," stop and reconsider.
Performance Impact
The difference is measurable. A page that renders a data table with 100 rows as a Server Component sends roughly 15-20KB of HTML. The same table as a Client Component sends the HTML plus 40-80KB of JavaScript for React to hydrate it, even though nothing on the table is interactive.
For a trading dashboard that displays market data, indicator values, and historical charts, moving read-only sections to Server Components typically reduces the JavaScript bundle by 30-50%. This translates directly to faster Time to Interactive, especially on mobile devices and slower connections.
Conclusion
React Server Components are not a paradigm shift that requires rewriting your application. They are a set of tools for making better decisions about where code runs. The seven patterns above cover the vast majority of real-world scenarios:
- Data Boundary: Fetch on the server, pass as props
- Island: Keep interactive pieces small and isolated
- Composition: Pass Server Components through children slots
- Streaming: Use Suspense for slow data without blocking
- Server Action: Mutate data without API routes
- Conditional Client: Ship JavaScript only when needed
- Parallel Data: Fetch everything at once with Promise.all
Start by auditing your existing components. For each "use client" directive, ask whether it is truly necessary. More often than not, you can push the boundary deeper into the tree, keeping more of your application server-rendered and your users' browsers lighter.