Server Component Performance — Reduce client JS by 30-70% with React Server Components
Recipe
// Server Component (default in Next.js App Router) — zero client JS
// app/products/page.tsx
import { ProductFilters } from "./ProductFilters"; // client component
import { db } from "@/lib/db";
export default async function ProductsPage() {
// Data fetching happens on the server — no fetch(), no loading state, no client JS
const products = await db.product.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
return (
<div>
<h1>Products</h1>
{/* Only this small component ships JS to the client */}
<ProductFilters />
{/* Server-rendered list — zero client JS */}
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price}
</li>
))}
</ul>
</div>
);
}
// ProductFilters.tsx — small "use client" boundary
"use client";
import { useState } from "react";
export function ProductFilters() {
const [category, setCategory] = useState("all");
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
);
}When to reach for this: For every component by default. Start with Server Components and only add "use client" when the component needs interactivity (useState, useEffect, event handlers, browser APIs).
Working Example
// ---- BEFORE: Client-heavy page — 245KB gzipped client JS ----
// app/dashboard/page.tsx
"use client"; // Entire page is a client component!
import { useState, useEffect } from "react";
import { format } from "date-fns";
import { BarChart, Bar, XAxis, YAxis } from "recharts";
interface DashboardData {
revenue: number;
orders: number;
customers: number;
recentOrders: Order[];
chartData: ChartPoint[];
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/dashboard")
.then((res) => res.json())
.then((d) => {
setData(d);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (!data) return <div>Error</div>;
return (
<div className="grid grid-cols-3 gap-6">
{/* Stats cards — could be static HTML */}
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Revenue</p>
<p className="text-2xl font-bold">${data.revenue.toLocaleString()}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Orders</p>
<p className="text-2xl font-bold">{data.orders}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Customers</p>
<p className="text-2xl font-bold">{data.customers}</p>
</div>
{/* Chart — needs client JS but only 1 section */}
<div className="col-span-2">
<BarChart width={600} height={300} data={data.chartData}>
<XAxis dataKey="month" />
<YAxis />
<Bar dataKey="revenue" fill="#3b82f6" />
</BarChart>
</div>
{/* Order list — could be static HTML */}
<div>
<h2>Recent Orders</h2>
<ul>
{data.recentOrders.map((order) => (
<li key={order.id}>
{order.customer} — ${order.total}
<span className="text-gray-400 text-sm">
{format(new Date(order.date), "MMM dd")}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
// ---- AFTER: Server-first — 52KB gzipped client JS (79% reduction) ----
// app/dashboard/page.tsx — Server Component (default)
import { Suspense } from "react";
import { format } from "date-fns";
import { db } from "@/lib/db";
import { RevenueChart } from "./RevenueChart"; // only client component
export default async function DashboardPage() {
// Server-side data fetching — zero client JS, no loading state needed
const [stats, recentOrders] = await Promise.all([
db.stats.findFirst(),
db.order.findMany({ orderBy: { date: "desc" }, take: 10 }),
]);
return (
<div className="grid grid-cols-3 gap-6">
{/* Stats cards — pure server HTML, zero JS */}
<StatsCard label="Revenue" value={`$${stats.revenue.toLocaleString()}`} />
<StatsCard label="Orders" value={stats.orders.toString()} />
<StatsCard label="Customers" value={stats.customers.toString()} />
{/* Chart — only interactive section, wrapped in Suspense */}
<div className="col-span-2">
<Suspense fallback={<div className="h-72 animate-pulse bg-gray-100 rounded" />}>
<RevenueChart />
</Suspense>
</div>
{/* Order list — pure server HTML, zero JS */}
<div>
<h2 className="font-semibold mb-2">Recent Orders</h2>
<ul className="space-y-2">
{recentOrders.map((order) => (
<li key={order.id} className="flex justify-between">
<span>
{order.customer} — ${order.total}
</span>
<span className="text-gray-400 text-sm">
{format(order.date, "MMM dd")}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
// Pure server component — no "use client", no JS sent to browser
function StatsCard({ label, value }: { label: string; value: string }) {
return (
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
);
}
// RevenueChart.tsx — smallest possible client boundary
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
const BarChart = dynamic(
() => import("recharts").then((m) => m.BarChart),
{ ssr: false }
);
const Bar = dynamic(() => import("recharts").then((m) => m.Bar), { ssr: false });
const XAxis = dynamic(() => import("recharts").then((m) => m.XAxis), { ssr: false });
const YAxis = dynamic(() => import("recharts").then((m) => m.YAxis), { ssr: false });
export function RevenueChart() {
const [chartData, setChartData] = useState<ChartPoint[]>([]);
useEffect(() => {
fetch("/api/dashboard/chart")
.then((r) => r.json())
.then(setChartData);
}, []);
if (!chartData.length) return null;
return (
<BarChart width={600} height={300} data={chartData}>
<XAxis dataKey="month" />
<YAxis />
<Bar dataKey="revenue" fill="#3b82f6" />
</BarChart>
);
}What this demonstrates:
- Stats cards and order list moved from client to server: zero JS, instant rendering
date-fnsruns on the server only: 4KB removed from client bundle- Database queries replace API routes: no client-side fetch, no loading/error states
- Recharts loaded dynamically: 180KB deferred until chart section is visible
- Client bundle: 245KB to 52KB gzipped (79% reduction)
- TTFB improves because HTML streams with real data instead of loading spinners
Deep Dive
How It Works
- Server Components render on the server only — Their code (imports, logic, dependencies) never ships to the client. The output is a serialized React tree that the client hydrates without re-executing component logic.
- The
"use client"directive creates a boundary — Everything imported into a"use client"file becomes part of the client bundle, including its dependencies. This is why keeping the boundary as small as possible is critical. - Server Components can import client components — A Server Component can render a
"use client"component as a child. The server component renders on the server; the client component boundary is serialized and hydrated on the client. - Client components cannot import server components — But client components can receive server components as
childrenprops. This is the key composition pattern. - Dependency elimination — Libraries used only in Server Components (database clients, markdown parsers, date formatters, validation libraries) are excluded from the client bundle entirely.
Variations
Server Component wrapping Client Component (composition pattern):
// app/layout.tsx — Server Component
import { auth } from "@/lib/auth";
import { Sidebar } from "./Sidebar"; // client component
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await auth(); // Server-only: zero client JS
return (
<div className="flex">
{/* Pass server data as props to client component */}
<Sidebar userName={user.name} role={user.role} />
<main>{children}</main>
</div>
);
}
// Sidebar.tsx — client component receives server data as serializable props
"use client";
import { useState } from "react";
export function Sidebar({ userName, role }: { userName: string; role: string }) {
const [collapsed, setCollapsed] = useState(false);
return (
<nav className={collapsed ? "w-16" : "w-64"}>
<p>{userName} ({role})</p>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
</nav>
);
}Passing Server Components as children to avoid client boundary contamination:
// WRONG: Making the entire layout a client component
"use client";
function Layout() {
const [theme, setTheme] = useState("light");
return (
<div className={theme}>
<Header /> {/* Now a client component */}
<Sidebar /> {/* Now a client component */}
<MainContent /> {/* Now a client component — all JS shipped! */}
</div>
);
}
// RIGHT: Isolate state into a thin wrapper
// ThemeProvider.tsx
"use client";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
return <div className={theme}>{children}</div>;
}
// layout.tsx — Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<Header /> {/* Server Component — zero JS */}
<Sidebar /> {/* Server Component — zero JS */}
{children}
</ThemeProvider>
);
}TypeScript Notes
- Server Components can be
asyncfunctions. TypeScript infers the return type asPromise<JSX.Element>. - Props passed from Server to Client components must be serializable (no functions, classes, or Dates — use ISO strings).
- Use
React.ReactNodefor thechildrenprop in client wrapper components.
Gotchas
-
Marking an entire layout as "use client" — This forces all child components to be client components, even if they do not need interactivity. A layout with
"use client"can add 100KB+ to the client bundle. Fix: Keep layouts as Server Components. Extract interactive elements (theme toggle, navigation state) into small client components. -
Importing a server-only library in a client component — If a client component imports
prisma,fs, or other server-only modules, the build fails or the entire library is bundled for the client. Fix: Move server-only imports to Server Components and pass data down as props. -
Passing non-serializable props — Functions,
Dateobjects,Map/Set, and class instances cannot be serialized across the server-client boundary. Fix: Convert to serializable types: use ISO strings for dates, plain objects for class instances, and Server Actions for functions. -
Over-splitting into too many client components — Creating dozens of tiny
"use client"files for every button and input adds hydration overhead and complexity. Fix: Group related interactive elements into a single client component that represents a logical UI section. -
Assuming Server Components are cached — Server Components re-execute on every request by default (dynamic rendering). Fix: Use
export const revalidate = 3600orgenerateStaticParamsfor static rendering when data does not change per-request.
Alternatives
| Approach | Trade-off |
|---|---|
| Server Components (default) | Zero client JS; cannot use hooks or browser APIs |
"use client" components | Full interactivity; adds to client bundle |
| Dynamic imports | Defers loading; adds network request on first use |
| Islands architecture (Astro) | Similar concept; different framework |
| Static generation (SSG) | Pre-rendered at build; no runtime server cost |
| Edge rendering | Server Components at the CDN edge; lower latency |
FAQs
What is a React Server Component and why does it improve performance?
A Server Component renders on the server only. Its code, imports, and dependencies never ship to the client. This means libraries like date formatters, database clients, and markdown parsers add zero bytes to the client bundle.
When should you add "use client" to a component?
Only when the component needs interactivity: useState, useEffect, event handlers, or browser APIs. Default to Server Components and add "use client" as late as possible in the component tree.
Can a client component import a server component?
No. Client components cannot import Server Components directly. However, client components can receive Server Components as children props:
// ThemeProvider.tsx — "use client"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
return <div className={theme}>{children}</div>;
}Gotcha: What happens if you mark an entire layout as "use client"?
All child components become client components, even if they do not need interactivity. This can add 100KB+ to the client bundle. Keep layouts as Server Components and extract interactive elements into small client components.
Gotcha: What types of props can you pass from Server Components to Client Components?
Only serializable types: strings, numbers, booleans, arrays, plain objects, and null. Functions, Date objects, Map/Set, and class instances cannot cross the boundary. Use ISO strings for dates and Server Actions for functions.
How do you pass server-fetched data to a client component?
Fetch data in the Server Component and pass it as serializable props:
// page.tsx (Server Component)
const user = await auth();
return <Sidebar userName={user.name} role={user.role} />;How do Server Components affect TTFB?
Server Components stream HTML with real data instead of empty shells with loading spinners. The browser receives meaningful content on the first byte, improving both TTFB and FCP. No client-side fetch round-trip is needed.
Are Server Components cached by default?
No. Server Components re-execute on every request by default (dynamic rendering). Use export const revalidate = 3600 or generateStaticParams for static rendering when data does not change per-request.
How should you type an async Server Component in TypeScript?
export default async function Page(): Promise<JSX.Element> {
const data = await fetchData();
return <div>{data.name}</div>;
}TypeScript infers the return type as Promise<JSX.Element> automatically. Use React.ReactNode for children in client wrapper components.
Is it bad to have too many small "use client" components?
Yes. Creating dozens of tiny client files for every button and input adds hydration overhead and complexity. Group related interactive elements into a single client component that represents a logical UI section.
How do you prevent a server-only library from being bundled for the client?
Keep the import in a Server Component. If a client component accidentally imports prisma, fs, or other server-only modules, the build fails or the entire library is bundled. Move server-only imports to Server Components and pass data down as props.
What is the composition pattern for isolating client state without contaminating the tree?
// layout.tsx — Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<Header /> {/* Server Component */}
{children}
</ThemeProvider>
);
}ThemeProvider is "use client" but receives server-rendered children, keeping Header as a Server Component with zero client JS.
Related
- Bundle Optimization — Measuring and reducing what Server Components cannot eliminate
- Suspense & Streaming — Streaming Server Component output for faster TTFB
- Data Fetching Performance — Server Component data fetching patterns
- Next.js Caching — Caching Server Component output for repeat visits