Most teams adopt the Next.js App Router and immediately add "use client" to every component that does anything interactive. Within a week, they've recreated a fully client-rendered SPA with extra steps. The bundle sizes haven't changed, the performance hasn't improved, and the team is left wondering what the point of Server Components was.
The problem isn't the technology. It's a fundamental misunderstanding of the rendering model, the serialization boundary, and how component composition actually works in RSC. This post breaks down the mechanics so you can make informed decisions about where the server-client boundary belongs in your application.
The RSC Rendering Model
React Server Components introduce a split rendering pipeline. Understanding it requires following a request from start to finish.
Step 1: Server Rendering
When a request hits a Next.js App Router route, the server begins rendering the component tree starting from the root layout. Server Components execute on the server — they can access the filesystem, query databases directly, call internal APIs, and read environment variables. They run once per request (or once at build time for static routes) and never re-render on the client.
The output of server rendering is not HTML. It's a serialized representation called the RSC Payload (sometimes called "flight data" after the internal React Flight protocol). This payload is a compact, streaming-friendly format that describes the component tree structure along with the rendered output of server components and placeholders for client components.
Step 2: Streaming to the Client
The RSC Payload streams to the client as the server renders. This is a key distinction from traditional SSR, where the server must finish rendering the entire page before sending a response. With RSC, the server can flush rendered content incrementally as each Suspense boundary resolves.
The wire format looks roughly like this (simplified):
M1:{"id":"./src/components/InteractiveChart.js","chunks":["chunk-abc"],"name":""}
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Dashboard"}],["$","$L1",null,{"data":[{"month":"Jan","revenue":42000}]}]]}]
M1 references a client component module. J0 describes the component tree. $L1 is a reference to the client component defined by M1. The actual rendered output of server components is inlined directly — no JavaScript needed to reproduce it on the client.
Step 3: Client Reconstruction
The client-side React runtime receives the RSC Payload and reconstructs the component tree. For server-rendered parts, it simply inserts the rendered output — no component code is downloaded or executed. For client components (marked with $L references), it downloads the corresponding JavaScript chunks and hydrates them.
This is the fundamental performance win: JavaScript for server components is never sent to the client. A 200-line server component that queries a database, transforms data, and renders a complex layout contributes zero bytes to the client bundle.
The "use client" Boundary
The most common misconception: "use client" does not mean "this component only runs on the client." It means "this is the entry point into the client module graph." Everything imported by a file with the "use client" directive becomes part of the client bundle.
// components/Dashboard.tsx — Server Component (default)
import { Sidebar } from './Sidebar' // Server Component
import { ChartPanel } from './ChartPanel' // Server Component
import { FilterBar } from './FilterBar' // Has "use client"
export function Dashboard() {
const data = await fetchAnalytics()
return (
<div className="grid grid-cols-12">
<Sidebar />
<FilterBar categories={data.categories} />
<ChartPanel data={data.series} />
</div>
)
}
// components/FilterBar.tsx
"use client"
import { useState } from 'react'
import { DateRangePicker } from './DateRangePicker' // Also becomes client code
import { formatRange } from '../utils/dates' // Also becomes client code
export function FilterBar({ categories }: { categories: string[] }) {
const [selected, setSelected] = useState<string[]>([])
// ...
}
The "use client" in FilterBar.tsx creates a boundary. DateRangePicker and formatRange are pulled into the client bundle — even if they don't use any client-side APIs themselves. This cascading effect is where most teams accidentally inflate their bundles.
Visualizing the Boundary
Component Tree with Server/Client Boundary
============================================
Layout (server)
│
├── Header (server)
│ └── Logo (server)
│
├── Dashboard (server)
│ │
│ ├── Sidebar (server)
│ │ └── NavLinks (server)
│ │
│ ├── FilterBar ← "use client" ─── BOUNDARY ───
│ │ │ │
│ │ ├── DateRangePicker (client) │
│ │ └── DropdownFilter (client) │
│ │ │
│ └── ChartPanel (server) │
│ │ │
│ └── InteractiveChart ← "use client" ───
│ └── D3Bindings (client)
│
└── Footer (server)
─── = Everything below the boundary ships JS to the client
Server components above the boundary ship ZERO JS
The boundary is directional. A server component can import and render a client component. A client component cannot import a server component (though it can receive one as children — more on that in the composition section).
Data Fetching Patterns
RSC changes how and where you fetch data. There are three primary patterns, each with distinct tradeoffs.
Pattern 1: Server-Side Fetch (Async Server Components)
Server Components can be async functions. This is the simplest and most performant pattern for data that doesn't need client-side reactivity.
// app/dashboard/page.tsx — Server Component
import { db } from '@/lib/database'
export default async function DashboardPage() {
const metrics = await db.query(`
SELECT date, revenue, active_users
FROM daily_metrics
WHERE date >= NOW() - INTERVAL '30 days'
ORDER BY date ASC
`)
return (
<div>
<h1>Dashboard</h1>
<MetricsSummary data={metrics} />
<RevenueChart data={metrics} />
</div>
)
}
When to use it: The data is needed at render time, doesn't change based on user interaction, and there's no need for revalidation without a page transition. This covers the majority of data fetching in most applications — page content, navigation data, initial dashboard state.
Why it's fast: No client-side fetch waterfall. No loading spinners. No layout shift. The data is fetched on the server (which is typically on the same network as the database) and the rendered result streams to the client.
Pattern 2: useEffect-Based Fetching
The traditional client-side approach. Still necessary for data that depends on client-only state like authentication tokens in cookies, user interactions, or browser APIs.
"use client"
import { useState, useEffect } from 'react'
export function LiveMetrics({ endpoint }: { endpoint: string }) {
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(endpoint)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!cancelled) setMetrics(data)
} catch (e) {
if (!cancelled) setError(e.message)
}
}
load()
return () => { cancelled = true }
}, [endpoint])
if (error) return <div className="text-red-600">Failed to load: {error}</div>
if (!metrics) return <MetricsSkeleton />
return <MetricsDisplay data={metrics} />
}
When to use it: One-off fetches that depend on user interaction, browser state, or don't justify adding a caching layer. Polling or WebSocket-driven data that needs continuous updates.
The cost: The component renders empty first, then triggers a network request from the browser, then re-renders with data. The user sees a loading skeleton. You pay for a full round trip from the browser to your API.
Pattern 3: SWR / React Query (Client-Side with Caching)
For client-side data that needs caching, automatic revalidation, or optimistic updates.
"use client"
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(r => r.json())
export function UserActivity({ userId }: { userId: string }) {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${userId}/activity`,
fetcher,
{
refreshInterval: 30_000, // Re-fetch every 30 seconds
revalidateOnFocus: true, // Re-fetch when tab becomes active
dedupingInterval: 5_000, // Deduplicate requests within 5s
}
)
if (isLoading) return <ActivitySkeleton />
if (error) return <ErrorState retry={() => mutate()} />
return <ActivityFeed events={data.events} />
}
When to use it: Data that updates frequently and the user expects to see changes without a full page navigation. Dashboards with auto-refresh, notification feeds, collaborative features, or any scenario where you need optimistic mutations.
The tradeoff: You're adding SWR or React Query to your client bundle (SWR is ~4KB gzipped, React Query ~13KB). That's a reasonable cost when you genuinely need client-side caching semantics. It's wasteful when a server-side fetch would have sufficed.
Choosing the Right Pattern
Ask two questions:
- ✓Does this data depend on client-side state? (user interaction, browser APIs, real-time updates) → Client-side fetching.
- ✓Does this data need caching, revalidation, or optimistic updates? → SWR/React Query.
- ✓Neither? → Server-side fetch. This should be your default.
Serialization Constraints
The server-client boundary is a serialization boundary. Data crossing it must survive JSON-like serialization via the React Flight protocol. This is where many runtime errors originate.
What Can Cross the Boundary
These types serialize correctly as props from server to client components:
- ✓Primitives: strings, numbers, booleans, null, undefined
- ✓Plain objects and arrays (containing serializable values)
- ✓Date objects (serialized as ISO strings, reconstructed as Date on the client)
- ✓Map and Set (supported in React's serialization protocol)
- ✓Typed arrays (Uint8Array, etc.)
- ✓React elements passed as
childrenor JSX props
What Cannot Cross
// ❌ This will throw a serialization error
// Server Component
export default function Page() {
const handleClick = () => console.log('clicked') // Function!
return <ClientButton onClick={handleClick} />
// Error: Functions cannot be passed directly to Client Components
// unless you explicitly expose it by marking it with "use server".
}
// ❌ Class instances lose their prototype chain
class UserModel {
constructor(public name: string, public email: string) {}
getDisplayName() { return this.name.split(' ')[0] }
}
export default async function Page() {
const user = new UserModel('Jane Doe', '[email protected]')
return <ProfileCard user={user} />
// The client receives a plain object { name, email }
// user.getDisplayName() will throw — the method doesn't exist
}
// ❌ Symbols are not serializable
export default function Page() {
return <ClientComponent status={Symbol('active')} />
// Error: Symbol values cannot be serialized
}
How to Fix Boundary Violations
For event handlers: Define them inside the client component.
// ✅ Move the handler to the client side
// Server Component
export default async function Page() {
const items = await fetchItems()
return <ItemList items={items} />
}
// Client Component
"use client"
export function ItemList({ items }: { items: Item[] }) {
const handleDelete = (id: string) => {
// Handle deletion client-side
}
return items.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => handleDelete(item.id)}>Delete</button>
</div>
))
}
For server-side mutations: Use Server Actions.
// actions.ts
"use server"
export async function deleteItem(id: string) {
await db.items.delete(id)
revalidatePath('/items')
}
// Server Component
import { deleteItem } from './actions'
export default async function Page() {
const items = await fetchItems()
return <ItemList items={items} deleteAction={deleteItem} />
}
// Client Component — receives a server action reference, not a function
"use client"
export function ItemList({ items, deleteAction }) {
return items.map(item => (
<form action={deleteAction.bind(null, item.id)} key={item.id}>
<span>{item.name}</span>
<button type="submit">Delete</button>
</form>
))
}
Server Actions serialize as special references that the client can invoke via HTTP — they don't violate the serialization boundary.
Common Anti-Patterns
Anti-Pattern 1: Wrapping Entire Pages in "use client"
// ❌ This defeats the entire purpose of RSC
"use client"
import { useState } from 'react'
import { HeavyMarkdownRenderer } from 'some-markdown-lib' // 45KB gzipped
import { DataTable } from './DataTable'
import { Sidebar } from './Sidebar'
export default function DashboardPage() {
const [tab, setTab] = useState('overview')
return (
<div>
<Sidebar />
<DataTable />
<HeavyMarkdownRenderer content={changelog} />
<button onClick={() => setTab('settings')}>Settings</button>
</div>
)
}
The entire page — sidebar, data table, markdown renderer — ships as client JavaScript. The useState for a single tab toggle forced everything into the client bundle.
Fix: Extract the interactive part into a small client component.
// ✅ Only the tab switcher needs client JS
// page.tsx — Server Component
import { Sidebar } from './Sidebar'
import { DataTable } from './DataTable'
import { ChangelogSection } from './ChangelogSection'
import { TabSwitcher } from './TabSwitcher'
export default async function DashboardPage() {
const data = await fetchDashboardData()
return (
<div>
<Sidebar />
<TabSwitcher />
<DataTable data={data.table} />
<ChangelogSection content={data.changelog} />
</div>
)
}
// TabSwitcher.tsx
"use client"
import { useState } from 'react'
export function TabSwitcher() {
const [tab, setTab] = useState('overview')
return <button onClick={() => setTab('settings')}>{tab}</button>
}
Anti-Pattern 2: Using useEffect for Data Available at Render Time
// ❌ Unnecessary client-side fetch
"use client"
import { useEffect, useState } from 'react'
export function TeamList() {
const [teams, setTeams] = useState([])
useEffect(() => {
fetch('/api/teams').then(r => r.json()).then(setTeams)
}, [])
return teams.map(t => <div key={t.id}>{t.name}</div>)
}
If the data doesn't depend on user interaction or browser state, fetch it on the server:
// ✅ Server component — no JS shipped, no loading state
import { db } from '@/lib/database'
export async function TeamList() {
const teams = await db.teams.findMany()
return teams.map(t => <div key={t.id}>{t.name}</div>)
}
Anti-Pattern 3: Importing Heavy Libraries in Shared Components
// ❌ This forces moment.js (67KB gzipped) into the client bundle
// even when the component is used in server contexts
import moment from 'moment'
export function FormattedDate({ date }: { date: Date }) {
return <span>{moment(date).format('MMM D, YYYY')}</span>
}
If a server component imports this, it works fine — moment runs on the server and the output is a string. But if a client component imports FormattedDate, moment ends up in the client bundle. Use lighter alternatives or split the component:
// ✅ Use built-in APIs — zero bundle cost
export function FormattedDate({ date }: { date: Date }) {
return (
<span>
{date.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
})}
</span>
)
}
Performance Implications
The performance benefits of RSC are measurable and come from three sources.
JS Bundle Reduction
Every component that stays on the server is code the client never downloads. In a typical data-heavy application, 50–70% of components are presentational or data-fetching — they don't need useState, useEffect, or event handlers. Moving these to server components directly reduces the JavaScript bundle.
A page with 15 components where 10 are server components means the client only downloads code for 5 components plus the React runtime. The server components contribute their rendered output (HTML-like content in the RSC payload) but zero JavaScript.
TTFB and Streaming
Traditional SSR waits for the entire page to render before sending bytes. RSC with Suspense boundaries enables streaming: the server sends the shell immediately and streams in content as each async operation completes.
export default async function Page() {
return (
<div>
<Header /> {/* Sent immediately */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart /> {/* Streamed in when DB query finishes */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable /> {/* Streamed in independently */}
</Suspense>
</div>
)
}
The browser can begin parsing and rendering the <Header /> while the server is still querying the database for chart data. This drops TTFB for the visible content to near-zero server processing time.
Hydration Cost
Hydration is the process of attaching event listeners and state to server-rendered HTML so it becomes interactive. It's expensive — React must walk the entire DOM tree and reconcile it with the component tree.
With RSC, server components don't hydrate. Only client components go through hydration. If your page has 10KB of client components and 50KB of server components, hydration only processes the 10KB. This directly reduces Time to Interactive (TTI).
Composition Patterns
The Donut Pattern
A server component can pass its children to a client component. This is the most important composition pattern in RSC because it lets you keep content on the server while wrapping it in client-side interactivity.
// CollapsibleSection.tsx
"use client"
import { useState } from 'react'
export function CollapsibleSection({ title, children }: {
title: string
children: React.ReactNode
}) {
const [open, setOpen] = useState(true)
return (
<section>
<button onClick={() => setOpen(!open)}>{title}</button>
{open && <div>{children}</div>}
</section>
)
}
// page.tsx — Server Component
import { CollapsibleSection } from './CollapsibleSection'
import { db } from '@/lib/database'
export default async function Page() {
const report = await db.reports.findLatest()
return (
<CollapsibleSection title="Monthly Report">
{/* This content is rendered on the server — no JS cost */}
<h2>{report.title}</h2>
<p>{report.summary}</p>
<ReportTable rows={report.data} />
</CollapsibleSection>
)
}
The children prop is already rendered RSC output by the time it reaches CollapsibleSection. The client component receives pre-rendered content, not component code. The toggle interaction is client-side, but the report content ships zero JavaScript.
The Container/Island Pattern
Structure pages as server-rendered containers with client-side interactive islands:
// Server Component — the container
export default async function AnalyticsPage() {
const [chartData, tableData, filters] = await Promise.all([
fetchChartData(),
fetchTableData(),
fetchFilterOptions(),
])
return (
<div className="grid grid-cols-12 gap-6">
<aside className="col-span-2">
<Navigation items={navItems} /> {/* Server — static */}
</aside>
<main className="col-span-10">
<FilterBar options={filters} /> {/* Client island */}
<section className="grid grid-cols-2 gap-4">
<ChartContainer>
<InteractiveChart data={chartData} /> {/* Client island */}
</ChartContainer>
<div>
<StatsSummary stats={tableData.summary} /> {/* Server */}
<SortableTable {/* Client island */}
columns={tableData.columns}
rows={tableData.rows}
/>
</div>
</section>
</main>
</div>
)
}
The page fetches all data in parallel on the server, renders the layout and static content server-side, and only ships JavaScript for the three interactive islands: FilterBar, InteractiveChart, and SortableTable.
Case Study: Migrating a B2B Analytics Dashboard
A B2B SaaS platform had a customer-facing analytics dashboard built with Next.js Pages Router. The entire application was client-rendered — data fetched via useEffect and React Query, state managed with Zustand, charts rendered with Recharts. Every component was a client component by default because that's how the Pages Router worked.
The Stripe Systems engineering team migrated this application to the Next.js App Router with a deliberate RSC-first architecture.
Before: The Fully Client-Rendered Architecture
// pages/dashboard.tsx (Pages Router — everything is client-side)
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sidebar } from '@/components/Sidebar'
import { ChartPanel } from '@/components/ChartPanel'
import { DataTable } from '@/components/DataTable'
import { FilterBar } from '@/components/FilterBar'
import { StatsCards } from '@/components/StatsCards'
export default function DashboardPage() {
const [dateRange, setDateRange] = useState({ from: thirtyDaysAgo, to: today })
const [filters, setFilters] = useState<FilterState>({})
const { data: metrics, isLoading: metricsLoading } = useQuery({
queryKey: ['metrics', dateRange, filters],
queryFn: () => fetchMetrics(dateRange, filters),
})
const { data: tableData, isLoading: tableLoading } = useQuery({
queryKey: ['table', dateRange, filters],
queryFn: () => fetchTableData(dateRange, filters),
})
const { data: chartData, isLoading: chartLoading } = useQuery({
queryKey: ['chart', dateRange, filters],
queryFn: () => fetchChartData(dateRange, filters),
})
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<FilterBar
dateRange={dateRange}
onDateChange={setDateRange}
filters={filters}
onFilterChange={setFilters}
/>
<StatsCards data={metrics} loading={metricsLoading} />
<ChartPanel data={chartData} loading={chartLoading} />
<DataTable data={tableData} loading={tableLoading} />
</main>
</div>
)
}
Measured performance:
| Metric | Value |
|---|---|
| JS Bundle (gzipped) | 420KB |
| Largest Contentful Paint | 3.2s |
| Time to Interactive | 4.1s |
| Hydration Time | ~800ms |
The 420KB bundle included React Query (~13KB), Recharts (~52KB), Zustand (~3KB), date-fns (~20KB), the full component tree, and all utility functions. Every page load required the browser to download, parse, and execute all of this before the dashboard became interactive. Three sequential API calls from the browser added further latency.
After: RSC-First Architecture
The migration followed a principle: push everything to the server by default, pull into the client only what requires interactivity.
Component Tree Restructuring
DashboardPage (server)
├── DashboardShell (server) — layout grid, responsive breakpoints
│ ├── Sidebar (server) — static navigation, fetched from CMS
│ │ └── NavLinks (server) — rendered from route config
│ │
│ └── MainContent (server)
│ ├── FilterContainer (server) — fetches filter options from DB
│ │ ├── DateRangePicker (client) — calendar interaction
│ │ └── DropdownFilter (client) — select interaction
│ │
│ ├── StatsCards (server) — fetches + renders summary metrics
│ │
│ ├── ChartContainer (server) — fetches time-series data
│ │ └── InteractiveChart (client) — Recharts with tooltips/zoom
│ │
│ └── TableContainer (server) — fetches, sorts, paginates data
│ ├── SortableHeaders (client) — click-to-sort interaction
│ └── Pagination (client) — page navigation
The Migrated Code
// app/dashboard/page.tsx — Server Component
import { db } from '@/lib/database'
import { DashboardShell } from '@/components/DashboardShell'
import { Sidebar } from '@/components/Sidebar'
import { FilterContainer } from '@/components/FilterContainer'
import { StatsCards } from '@/components/StatsCards'
import { ChartContainer } from '@/components/ChartContainer'
import { TableContainer } from '@/components/TableContainer'
export default async function DashboardPage({
searchParams,
}: {
searchParams: { from?: string; to?: string; category?: string }
}) {
const dateRange = parseDateRange(searchParams)
const filters = parseFilters(searchParams)
// Parallel data fetching on the server — same network as the DB
const [navItems, filterOptions, stats, chartData, tableData] = await Promise.all([
db.navigation.findMany(),
db.filters.getOptions(),
db.metrics.getSummary(dateRange, filters),
db.metrics.getTimeSeries(dateRange, filters),
db.metrics.getTableData(dateRange, filters),
])
return (
<DashboardShell>
<Sidebar items={navItems} />
<main className="flex-1 p-6 space-y-6">
<FilterContainer
options={filterOptions}
currentRange={dateRange}
currentFilters={filters}
/>
<StatsCards stats={stats} />
<ChartContainer data={chartData} />
<TableContainer data={tableData} />
</main>
</DashboardShell>
)
}
// components/ChartContainer.tsx — Server Component
import { InteractiveChart } from './InteractiveChart'
interface ChartContainerProps {
data: TimeSeriesPoint[]
}
export function ChartContainer({ data }: ChartContainerProps) {
// Data transformation runs on the server — no client JS cost
const formatted = data.map(point => ({
date: point.date.toISOString().split('T')[0],
revenue: Math.round(point.revenue / 100), // cents to dollars
users: point.activeUsers,
}))
return (
<section className="rounded-lg border bg-white p-4">
<h2 className="text-lg font-semibold mb-4">Revenue & Users</h2>
<InteractiveChart data={formatted} />
</section>
)
}
// components/InteractiveChart.tsx — Client Component
"use client"
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
} from 'recharts'
interface InteractiveChartProps {
data: { date: string; revenue: number; users: number }[]
}
export function InteractiveChart({ data }: InteractiveChartProps) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<XAxis dataKey="date" />
<YAxis yAxisId="revenue" orientation="left" />
<YAxis yAxisId="users" orientation="right" />
<Tooltip />
<Legend />
<Line yAxisId="revenue" dataKey="revenue" stroke="#4f46e5" />
<Line yAxisId="users" dataKey="users" stroke="#10b981" />
</LineChart>
</ResponsiveContainer>
)
}
// components/FilterContainer.tsx — Server Component
import { DateRangePicker } from './DateRangePicker'
import { DropdownFilter } from './DropdownFilter'
interface FilterContainerProps {
options: FilterOption[]
currentRange: DateRange
currentFilters: Record<string, string>
}
export function FilterContainer({
options,
currentRange,
currentFilters,
}: FilterContainerProps) {
return (
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<DateRangePicker initial={currentRange} />
{options.map(opt => (
<DropdownFilter
key={opt.key}
label={opt.label}
options={opt.values}
selected={currentFilters[opt.key]}
/>
))}
</div>
)
}
// components/DateRangePicker.tsx — Client Component
"use client"
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
export function DateRangePicker({ initial }: { initial: DateRange }) {
const router = useRouter()
const searchParams = useSearchParams()
const [range, setRange] = useState(initial)
function applyRange(newRange: DateRange) {
setRange(newRange)
const params = new URLSearchParams(searchParams.toString())
params.set('from', newRange.from)
params.set('to', newRange.to)
router.push(`?${params.toString()}`)
}
return (
<div className="flex items-center gap-2">
<input
type="date"
value={range.from}
onChange={e => applyRange({ ...range, from: e.target.value })}
/>
<span>to</span>
<input
type="date"
value={range.to}
onChange={e => applyRange({ ...range, to: e.target.value })}
/>
</div>
)
}
A critical design decision: filters update URL search params via router.push, which triggers a server-side re-render of the page. This means the data refetch happens on the server — no React Query, no client-side caching layer, no loading waterfall. The user changes a filter, the URL updates, Next.js fetches fresh RSC payload from the server with the new data, and the page updates with a smooth transition.
TableContainer: Server Fetching with Client Interactivity
// components/TableContainer.tsx — Server Component
import { SortableHeaders } from './SortableHeaders'
import { Pagination } from './Pagination'
export function TableContainer({ data }: { data: TableData }) {
return (
<section className="rounded-lg border bg-white">
<table className="w-full">
<thead>
<SortableHeaders columns={data.columns} />
</thead>
<tbody>
{data.rows.map(row => (
<tr key={row.id} className="border-t">
{data.columns.map(col => (
<td key={col.key} className="px-4 py-2">
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
/>
</section>
)
}
The table body is rendered on the server — potentially hundreds of rows, with formatting and layout, contributing zero client JavaScript. Only the sort headers (click handlers) and pagination controls (navigation) are client components.
Results
After migration, measured on a representative set of customer accounts with production data:
| Metric | Before | After | Change |
|---|---|---|---|
| JS Bundle (gzipped) | 420KB | 180KB | -57% |
| Largest Contentful Paint | 3.2s | 1.1s | -66% |
| Time to Interactive | 4.1s | 1.8s | -56% |
| Hydration Time | ~800ms | ~200ms | -75% |
The bundle reduction came from three sources: Recharts was still shipped (it's a client component dependency) but React Query, Zustand, and most utility libraries were eliminated from the client bundle. Data transformation functions that previously ran in the browser now run on the server. The Sidebar, StatsCards, table body, and layout components all became zero-JS server components.
The LCP improvement was primarily from eliminating the client-side fetch waterfall. Previously, the browser had to download JS → parse → execute → fetch data → render. Now, the server fetches data on the same network as the database, renders the content, and streams the result. The first meaningful content arrives in the initial response.
Hydration time dropped because there was simply less to hydrate. Five small interactive islands instead of an entire page worth of components.
Practical Takeaways
- ✓
Default to server components. In the App Router, components are server components unless you add
"use client". Respect this default. Only opt into client components when you need browser APIs, event handlers, or React state. - ✓
Push the "use client" boundary as deep as possible. Don't make a container a client component because it contains one interactive child. Make the child a client component and keep the container on the server.
- ✓
Use URL state instead of React state where feasible. Search params, read on the server via
searchParams, mean your filters, pagination, and sorting can drive server-side data fetching without any client-side state management library. - ✓
Fetch data where it's consumed. Server components can fetch data directly. You don't need to fetch everything at the page level and drill props. Colocate the fetch with the component that renders the data — Next.js deduplicates
fetchcalls automatically. - ✓
Audit your imports. Run
@next/bundle-analyzerregularly. Look for large libraries pulled into the client bundle by a"use client"component. Often, a server-component-friendly alternative exists or the import can be restructured. - ✓
Don't fight the model. If you find yourself passing complex state between many client components and working around serialization constraints, step back. You might be trying to build a SPA inside an RSC framework. Either restructure to use the server more or evaluate whether RSC is the right fit for that particular feature.
The RSC model is a tradeoff, not a universal improvement. It excels at content-heavy, data-driven applications where most of the UI is presentational. It adds complexity to highly interactive, real-time applications where most components need client-side state. Understand where your application falls on that spectrum and draw the boundary accordingly.
Ready to discuss your project?
Get in Touch →