Next.js — Server & Client Components¶
OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x, react@19.x
Overview¶
React Server Components (RSC) are the default in App Router. Components render on the server,
ship zero JavaScript to the client, and can directly access databases and APIs.
Client Components are opt-in via "use client" directive. The boundary placement is critical.
Decision Tree: Server vs Client Component¶
START: Does this component need...
│
├─ Browser APIs? (window, localStorage, navigator, IntersectionObserver)
│ └─ YES → Client Component
│
├─ React state? (useState, useReducer)
│ └─ YES → Client Component
│
├─ React effects? (useEffect, useLayoutEffect)
│ └─ YES → Client Component
│
├─ Event handlers? (onClick, onChange, onSubmit)
│ └─ YES → Client Component
│
├─ React context? (useContext)
│ └─ YES → Client Component (context provider must be client)
│
├─ Third-party hooks? (useForm, useQuery, useTheme)
│ └─ YES → Client Component
│
└─ NONE OF THE ABOVE → Server Component (default, no directive needed)
IF: component only displays data fetched from DB/API
THEN: Server Component — zero JS shipped
IF: component needs interactivity + data
THEN: Server Component parent (fetches data) → Client Component child (handles interaction)
IF: component uses a library that requires "use client"
THEN: wrap it in a thin Client Component, keep surrounding tree as Server Components
Server Components — Rules¶
Server Components (default in app/ directory):
CAN:
- Fetch data directly (await in component body)
- Access database, filesystem, internal APIs
- Import and render other Server Components
- Import and render Client Components
- Use async/await at the component level
CANNOT:
- Use useState, useReducer, useEffect, useContext
- Use browser APIs (window, document, localStorage)
- Use event handlers (onClick, onChange)
- Be imported by Client Components
// src/app/(dashboard)/projects/page.tsx — Server Component (no directive)
import { db } from "@/lib/db";
import { ProjectList } from "@/components/projects/project-list";
export default async function ProjectsPage() {
const projects = await db.query.projects.findMany({
where: (p, { eq }) => eq(p.status, "active"),
});
return <ProjectList projects={projects} />;
}
Client Components — Rules¶
"use client"; // MUST be first line (before imports)
import { useState } from "react";
export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch(query)}
/>
);
}
CHECK: "use client" is the absolute first line of the file
CHECK: no blank lines or comments before "use client"
IF: "use client" is not first line
THEN: component silently becomes Server Component — runtime errors on hooks
CRITICAL: "use client" marks a BOUNDARY, not just the component.
All imports of a Client Component file become part of the client bundle.
This means: if a Client Component imports a utility that imports a heavy library,
that entire library ships to the client.
The Boundary Pattern (GE Standard)¶
Push "use client" to the leaves of the component tree.
Page (Server) ─── fetches data
├── Header (Server) ─── static, renders HTML
│ └── UserMenu (Client) ─── dropdown interaction
├── ProjectTable (Server) ─── renders data as HTML
│ ├── TableRow (Server) ─── static row
│ │ └── ActionButtons (Client) ─── click handlers
│ └── Pagination (Client) ─── state for current page
└── Footer (Server) ─── static
ANTI_PATTERN: adding "use client" to the page component
FIX: keep page as Server Component, extract interactive parts to Client Components
ANTI_PATTERN: adding "use client" to a layout
FIX: layouts should almost always be Server Components — extract interactive nav to Client Component
ANTI_PATTERN: creating a "use client" wrapper component just to use children
FIX: Server Components can be passed as children prop to Client Components (composition pattern)
Composition Pattern: Server Children in Client Parents¶
// Client Component — receives server-rendered children
"use client";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<aside className={open ? "w-64" : "w-16"}>
<button onClick={() => setOpen(!open)}>Toggle</button>
{children} {/* Server-rendered content passed through */}
</aside>
);
}
// Server Component — passes server content as children
import { Sidebar } from "@/components/sidebar";
import { NavLinks } from "@/components/nav-links"; // Server Component
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar>
<NavLinks /> {/* Rendered on server, passed as children */}
</Sidebar>
<main>{children}</main>
</div>
);
}
This works because children is a serializable React node. The Server Component renders
the content, and the Client Component receives it as pre-rendered HTML.
Streaming & Suspense¶
Server Components support streaming — send HTML progressively as data becomes available.
loading.tsx (Route-Level Suspense)¶
// src/app/(dashboard)/projects/loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
Component-Level Suspense¶
import { Suspense } from "react";
import { ProjectStats } from "@/components/projects/project-stats";
import { RecentActivity } from "@/components/projects/recent-activity";
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<Skeleton className="h-48" />}>
<ProjectStats /> {/* Fetches independently, streams when ready */}
</Suspense>
<Suspense fallback={<Skeleton className="h-48" />}>
<RecentActivity /> {/* Fetches independently, streams when ready */}
</Suspense>
</div>
);
}
CHECK: independent data fetches are wrapped in separate Suspense boundaries
IF: two components fetch different data
THEN: wrap each in its own Suspense — they load in parallel, stream independently
ANTI_PATTERN: one Suspense boundary wrapping the entire page
FIX: granular Suspense boundaries per independent data source
ANTI_PATTERN: not wrapping useSearchParams() in Suspense
IF: useSearchParams() is used without Suspense wrapper
THEN: build error or runtime crash in Next.js 15+
FIX: split into inner component (with hook) + outer component (with Suspense)
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
function SearchResultsInner() {
const searchParams = useSearchParams();
const query = searchParams.get("q") ?? "";
return <div>Results for: {query}</div>;
}
export function SearchResults() {
return (
<Suspense fallback={<div>Loading search...</div>}>
<SearchResultsInner />
</Suspense>
);
}
Data Passing: Server → Client¶
Serialization rules for passing props from Server to Client Components:
ALLOWED: strings, numbers, booleans, null, arrays, plain objects, Date, Map, Set,
TypedArrays, FormData, Promises (for use())
NOT ALLOWED: functions, class instances, Symbols, DOM nodes, streams
IF: you need to pass a function to a Client Component
THEN: use Server Actions (form actions) or define the function in the Client Component
IF: you need to pass a complex object
THEN: serialize it to a plain object first — no Drizzle model instances as props
// WRONG — passing Drizzle result directly
<ClientComponent project={drizzleResult} />
// RIGHT — serialize to plain object
const project = {
id: drizzleResult.id,
name: drizzleResult.name,
createdAt: drizzleResult.createdAt.toISOString(),
};
<ClientComponent project={project} />
Context Providers¶
React Context requires Client Components. Wrap providers at the highest needed level.
// src/components/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<Toaster />
</ThemeProvider>
);
}
// src/app/layout.tsx — Server Component
import { Providers } from "@/components/providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
CHECK: context providers are in a single Providers wrapper component
CHECK: Providers is the only "use client" boundary in the root layout
CHECK: children of Providers can still be Server Components (composition pattern)
Third-Party Library Compatibility¶
IF: library docs say "add 'use client'"
THEN: create a thin wrapper, do NOT add "use client" to your page
// src/components/ui/chart-wrapper.tsx
"use client";
export { BarChart, LineChart, PieChart } from "recharts";
IF: library throws "useState is not defined" or similar
THEN: the library needs "use client" — wrap it
IF: library works without hooks/browser APIs
THEN: it can be used in Server Components directly
CHECK: audit third-party imports in Server Components during code review
TOOL: npx next info — shows which packages need client wrapping
Anti-Patterns Summary¶
ANTI_PATTERN: "use client" on page.tsx or layout.tsx
FIX: extract interactive parts to leaf Client Components
ANTI_PATTERN: fetching data in Client Components that could be Server Components
FIX: fetch in Server Component parent, pass as props
ANTI_PATTERN: importing server-only code in Client Components (db, fs, env secrets)
FIX: use import "server-only" package to get build-time errors
// src/lib/db/index.ts
import "server-only"; // Fails at build time if imported from Client Component
import { drizzle } from "drizzle-orm/postgres-js";
ANTI_PATTERN: wrapping entire subtrees in "use client" because one child needs it
FIX: use composition pattern — pass Server Components as children
Cross-References¶
READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/data-fetching.md
READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md
READ_ALSO: wiki/docs/stack/nextjs/performance.md