React Server Components: Mentale Modellen, Patronen en Valkuilen
De praktische gids voor React Server Components die ik wenste dat bestond toen ik begon. Mentale modellen, echte patronen, het boundary-probleem, en fouten die ik maakte zodat jij dat niet hoeft.
De eerste drie maanden met React Server Components dacht ik dat ik ze begreep. Ik had de RFC's gelezen, de conferentietalks bekeken, een paar demo-apps gebouwd. Ik had vol overtuiging ongelijk over bijna alles.
Het probleem is niet dat RSC ingewikkeld is. Het is dat het mentale model oprecht anders is dan alles wat we eerder in React hebben gedaan, en iedereen — inclusief ikzelf — probeert het in het oude kader te passen. "Het is net als SSR." Nee, dat is het niet. "Het is als PHP." Dichter in de buurt, maar nee. "Het zijn gewoon componenten die op de server draaien." Technisch waar, praktisch nutteloos.
Wat volgt is alles wat ik daadwerkelijk moest weten, geschreven zoals ik wenste dat iemand het aan mij had uitgelegd. Niet de theoretische versie. De versie waarbij je om 11 uur 's avonds naar een serialisatiefout staart en moet begrijpen waarom.
Het Mentale Model Dat Echt Werkt#
Vergeet even alles wat je weet over React-rendering. Hier is het nieuwe plaatje.
In traditioneel React (client-side) wordt je hele componentboom als JavaScript naar de browser gestuurd. De browser downloadt het, parst het, voert het uit en rendert het resultaat. Elk component — of het nu een interactief formulier van 200 regels is of een statische paragraaf tekst — gaat door dezelfde pipeline.
React Server Components splitsen dit in twee werelden:
Server Components draaien op de server. Ze worden één keer uitgevoerd, produceren hun output, en sturen het resultaat naar de client — niet de code. De browser ziet de componentfunctie nooit, downloadt de dependencies nooit, en re-rendert het nooit.
Client Components werken zoals traditioneel React. Ze worden naar de browser gestuurd, gehydrateerd, behouden state en verwerken events. Het is het React dat je al kent.
Het kerninzicht dat mij beschamend lang kostte om te internaliseren: Server Components zijn de default. In Next.js App Router is elk component een Server Component tenzij je het expliciet naar de client opt-in met "use client". Dit is het tegenovergestelde van wat we gewend zijn, en het verandert hoe je over compositie nadenkt.
De Rendering Waterval#
Dit is wat er daadwerkelijk gebeurt wanneer een gebruiker een pagina opvraagt:
1. Request bereikt de server
2. Server voert Server Components top-down uit
3. Wanneer een Server Component een "use client" boundary raakt,
stopt het — die subtree rendert op de client
4. Server Components produceren RSC Payload (een speciaal formaat)
5. RSC Payload streamt naar de client
6. Client rendert Client Components en naait ze in de
server-gerenderde boom
7. Hydration maakt Client Components interactief
Stap 4 is waar de meeste verwarring zit. De RSC Payload is geen HTML. Het is een speciaal streaming-formaat dat de componentboom beschrijft — wat de server renderde, waar de client moet overnemen, en welke props over de boundary worden doorgegeven.
Het ziet er ongeveer zo uit (vereenvoudigd):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
Je hoeft dit formaat niet uit je hoofd te leren. Maar begrijpen dat het bestaat — dat er een serialisatielaag is tussen server en client — bespaart je uren debuggen. Elke keer dat je een "Props must be serializable" error krijgt, komt dat omdat iets wat je doorgeeft deze vertaling niet overleeft.
Wat "Draait op de Server" Echt Betekent#
Wanneer ik zeg dat een Server Component "op de server draait," meen ik dat letterlijk. De componentfunctie wordt uitgevoerd in Node.js (of Edge runtime). Dit betekent dat je kunt:
// app/dashboard/page.tsx — dit is standaard een Server Component
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Directe database-query. Geen API-route nodig.
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welkom terug, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}Geen useEffect. Geen loading state management. Geen API-route om dingen aan elkaar te lijmen. Het component is de datalaag. Dit is de grootste winst van RSC en het voelde in het begin het meest oncomfortabel, omdat ik bleef denken "maar waar is de scheiding?"
De scheiding is de "use client" boundary. Alles erboven is server. Alles eronder is client. Dat is je architectuur.
De Server/Client Boundary#
Dit is waar het begrip van de meeste mensen stukloopt, en waar ik de meeste debugtijd doorbracht in de eerste paar maanden.
De "use client" Directive#
De "use client" directive bovenaan een bestand markeert alles wat uit dat bestand geëxporteerd wordt als Client Component. Het is een annotatie op moduleniveau, niet op componentniveau.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// Dit hele bestand is nu "client-gebied"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Teller: {count}</p>
<button onClick={() => setCount(count + 1)}>Verhogen</button>
</div>
);
}
// Dit is OOK een Client Component omdat het in hetzelfde bestand staat
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}Veelgemaakte fout: "use client" in een barrel-bestand (index.ts) zetten dat alles re-exporteert. Gefeliciteerd, je hebt zojuist je hele componentbibliotheek client-side gemaakt. Ik heb teams zo per ongeluk 200KB JavaScript zien verzenden.
Wat de Boundary Oversteekt#
Hier is de regel die je redt: alles wat de server-client boundary oversteekt moet serialiseerbaar zijn naar JSON.
Wat is serialiseerbaar:
- Strings, numbers, booleans, null, undefined
- Arrays en platte objecten (met serialiseerbare waarden)
- Dates (geserialiseerd als ISO strings)
- Server Components (als JSX — daar komen we op terug)
- FormData
- Typed arrays, ArrayBuffer
Wat NIET serialiseerbaar is:
- Functies (inclusief event handlers)
- Klassen (instanties van custom klassen)
- Symbols
- DOM-nodes
- Streams (in de meeste contexten)
Dit betekent dat je dit niet kunt doen:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// FOUT: Functies zijn niet serialiseerbaar
onItemClick={(id) => console.log(id)}
// FOUT: Klasse-instanties zijn niet serialiseerbaar
formatter={new Intl.NumberFormat("en-US")}
/>
);
}De oplossing is niet om de pagina een Client Component te maken. De oplossing is om interactiviteit naar beneden en data fetching naar boven te duwen:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Geef alleen serialiseerbare data door
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Maak de formatter aan op de client
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}Het "Eiland"-Misverstand#
Ik dacht aanvankelijk aan Client Components als "eilandjes" — kleine interactieve stukjes in een zee van server-gerenderde content. Dat is gedeeltelijk juist maar mist een cruciaal detail: een Client Component kan Server Components renderen als ze als children of props worden doorgegeven.
Dit betekent dat de boundary geen harde muur is. Het is meer een membraan. Server-gerenderde content kan door Client Components stromen via het children-patroon. We gaan hier dieper op in bij het compositiegedeelte.
Data Fetching Patronen#
RSC verandert data fetching fundamenteel. Geen useEffect + useState + loading states meer voor data die bekend is bij rendertijd. Maar de nieuwe patronen hebben hun eigen valkuilen.
Eenvoudige Fetch met Caching#
In een Server Component fetch je gewoon. Next.js breidt de globale fetch uit met caching:
// app/products/page.tsx
export default async function ProductsPage() {
// Standaard gecacht — dezelfde URL retourneert gecacht resultaat
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Beheer het caching-gedrag expliciet:
// Hervalideer elke 60 seconden (ISR-achtig gedrag)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// Geen caching — altijd verse data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache met tags voor on-demand hervalidatie
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Vervolgens kun je hervalideren op tag vanuit een Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Parallelle Data Fetching#
De meest voorkomende prestatiefout die ik zie: sequentiële data fetching wanneer parallel ook zou werken.
Slecht — sequentieel (watervallen):
// app/dashboard/page.tsx — DOE DIT NIET
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Totaal: 650ms — elk wacht op de vorige
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Goed — parallel:
// app/dashboard/page.tsx — DOE DIT
export default async function Dashboard() {
// Alle drie vuren tegelijkertijd af
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (draait parallel)
getNotifications(), // 150ms (draait parallel)
]);
// Totaal: ~300ms — gelimiteerd door het langzaamste verzoek
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Nog beter — parallel met onafhankelijke Suspense boundaries:
// app/dashboard/page.tsx — BESTE
import { Suspense } from "react";
export default function Dashboard() {
// Let op: dit component is NIET async — het delegeert naar kinderen
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Elk component fetcht zijn eigen data
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}Dit laatste patroon is het krachtigste omdat elke sectie onafhankelijk laadt. De gebruiker ziet content zodra het beschikbaar wordt, niet alles-of-niets. De snelle secties wachten niet op de trage.
Request Deduplicatie#
Next.js dedupliceert automatisch fetch-calls met dezelfde URL en opties tijdens een enkele renderpass. Dit betekent dat je data fetching niet hoeft op te tillen om redundante verzoeken te vermijden:
// Beide componenten kunnen dezelfde URL fetchen
// en Next.js maakt maar ÉÉN daadwerkelijk HTTP-verzoek
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welkom, {user.name}</nav>;
}
async function Sidebar() {
// Dezelfde URL — automatisch gededupliceerd, geen tweede verzoek
const user = await fetch("/api/user").then(r => r.json());
return <aside>Rol: {user.role}</aside>;
}Belangrijk voorbehoud: dit werkt alleen met fetch. Als je een ORM of databaseclient direct gebruikt, moet je React's cache() functie gebruiken:
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap je datafunctie met cache()
// Nu resulteren meerdere aanroepen in dezelfde render in één query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() dedupliceert voor de levensduur van een enkel serververzoek. Het is geen persistente cache — het is per-request memoization. Nadat het verzoek klaar is, worden de gecachte waarden opgeruimd door de garbage collector.
Component Compositie Patronen#
Dit is waar RSC oprecht elegant wordt, zodra je de patronen begrijpt. En oprecht verwarrend, tot je dat doet.
Het "Children als Gat" Patroon#
Dit is het belangrijkste compositiepatroon in RSC en het kostte me weken om het volledig te waarderen. Het probleem: je hebt een Client Component dat layout of interactiviteit biedt, en je wilt Server Components erin renderen.
Je kunt geen Server Component importeren in een Client Component-bestand. Zodra je "use client" toevoegt, is alles in die module client-side. Maar je kunt Server Components als children doorgeven:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Sluiten" : "Openen"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* Deze children kunnen Server Components zijn! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* Dit zijn Server Components, doorgegeven via een Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}Waarom werkt dit? Omdat de Server Components (UserProfile, NavigationLinks) eerst op de server gerenderd worden, waarna hun output (de RSC payload) als children aan het Client Component wordt doorgegeven. Het Client Component hoeft nooit te weten dat het Server Components waren — het ontvangt gewoon vooraf gerenderde React-nodes.
Denk aan children als een "gat" in het Client Component waar server-gerenderde content doorheen kan stromen.
Server Components als Props Doorgeven#
Het children-patroon generaliseert naar elke prop die React.ReactNode accepteert:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — kan data fetchen
import { BillingSettings } from "./BillingSettings"; // Server Component — kan data fetchen
import { SecuritySettings } from "./SecuritySettings"; // Server Component — kan data fetchen
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profiel", content: <ProfileSettings /> },
{ label: "Facturatie", content: <BillingSettings /> },
{ label: "Beveiliging", content: <SecuritySettings /> },
]}
/>
);
}Elk instellingencomponent kan een async Server Component zijn dat zijn eigen data fetcht. Het Client Component (TabLayout) handelt alleen de tabwisseling af. Dit is een ongelofelijk krachtig patroon.
Async Server Components#
Server Components kunnen async zijn. Dit is een grote zaak omdat het betekent dat data fetching tijdens rendering plaatsvindt, niet als bijwerking:
// Dit is geldig en prachtig
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>Door {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Client Components kunnen niet async zijn. Als je probeert een "use client" component async te maken, gooit React een fout. Dit is een harde beperking.
Suspense Boundaries: De Streaming Primitief#
Suspense is hoe je streaming krijgt in RSC. Zonder Suspense boundaries wacht de hele pagina op het langzaamste async component. Met Suspense boundaries streamt elke sectie onafhankelijk:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Statisch — rendert onmiddellijk */}
<HeroSection />
{/* Snelle data — verschijnt snel */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Gemiddelde snelheid — verschijnt wanneer klaar */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Traag (ML-aangedreven) — verschijnt als laatste, blokkeert de rest niet */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}De gebruiker ziet HeroSection direct, vervolgens streamt ProductGrid in, dan reviews, dan aanbevelingen. Elke Suspense boundary is een onafhankelijk streamingpunt.
Geneste Suspense boundaries zijn ook geldig en nuttig:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Als Dashboard snel is maar RevenueChart traag, lost de buitenste Suspense eerst op (toont de dashboard-shell), en de binnenste Suspense voor de grafiek lost later op.
Error Boundaries met Suspense#
Combineer Suspense met error.tsx voor veerkrachtige UI's:
// app/dashboard/error.tsx — Client Component (moet dat zijn)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Er ging iets mis bij het laden van het dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Opnieuw proberen
</button>
</div>
);
}Het error.tsx bestand wrapt automatisch het corresponderende routesegment in een React Error Boundary. Als een Server Component in dat segment een fout gooit, verschijnt de fout-UI in plaats van dat de hele pagina crasht.
Wanneer Wat Gebruiken: De Beslisboom#
Na het bouwen van meerdere productie-apps met RSC heb ik een duidelijk besliskader ontwikkeld. Dit is het daadwerkelijke denkproces dat ik voor elk component doorloop:
Begin Met Server Components (de Default)#
Elk component zou een Server Component moeten zijn tenzij er een specifieke reden is waarom dat niet kan. Dit is de allerbelangrijkste regel.
Maak Het een Client Component Wanneer:#
1. Het browser-only API's gebruikt
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Locatie laden...</p>;
}2. Het React hooks gebruikt die state of effects vereisen
useState, useEffect, useReducer, useRef (voor mutable refs), useContext — elk van deze vereist "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Dit component MOET een Client Component zijn omdat het
// useState gebruikt en gebruikersinvoer beheert
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Zoeken..."
/>
);
}3. Het event handlers bijkoppelt
onClick, onChange, onSubmit, onMouseEnter — elk interactief gedrag betekent client-side.
4. Het client-side bibliotheken gebruikt
Framer Motion, React Hook Form, Zustand, React Query (voor client-side fetching), elke chartbibliotheek die rendert naar canvas of interactieve SVG.
Houd Het als Server Component Wanneer:#
- Het alleen data toont (geen gebruikersinteractie)
- Het data fetcht van een database of API
- Het backend-resources benadert (bestandssysteem, env-variabelen met secrets)
- Het grote dependencies importeert die de client niet nodig heeft (markdown parsers, syntax highlighters, datumbibliotheken voor formattering)
- Het statische of semi-statische content rendert
De Beslissing in de Praktijk#
Hier is een concreet voorbeeld. Ik bouw een productpagina:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — statische navigatie, geen interactiviteit
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — alleen tekst
│ ├── ProductPrice (Server) — geformatteerd getal, geen interactie
│ └── AddToCartButton (Client) — onClick, beheert winkelwagenstate
├── ProductDescription (Server) — gerenderde markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, trage API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — formulier met validatie
Let op het patroon: de pagina-shell en data-intensieve delen zijn Server Components. Interactieve eilandjes (ImageGallery, AddToCartButton, ReviewForm) zijn Client Components. Trage secties (RelatedProducts, ProductReviews) zijn gewrapt in Suspense.
Dit is niet theoretisch. Dit is hoe mijn componentbomen er daadwerkelijk uitzien.
Veelgemaakte Fouten (Ik Heb Ze Allemaal Gemaakt)#
Fout 1: Alles een Client Component Maken#
De weg van de minste weerstand bij het migreren vanaf Pages Router of Create React App is om "use client" op alles te plakken. Het werkt! Niets breekt! Je stuurt ook je hele componentboom als JavaScript en krijgt nul RSC-voordelen.
Ik heb codebases gezien waar de root layout "use client" erop heeft staan. Op dat punt draai je letterlijk een client-side React app met extra stappen.
De oplossing: Begin met Server Components. Voeg "use client" alleen toe wanneer de compiler je vertelt dat het nodig is (omdat je een hook of event handler gebruikt). Duw "use client" zo ver mogelijk naar beneden in de boom.
Fout 2: Prop Drilling Door de Boundary#
// SLECHT: data fetchen in een Server Component, dan doorsturen door
// meerdere Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}Elk stuk data dat je door de boundary stuurt wordt geserialiseerd in de RSC payload. Geef je hetzelfde object vijf keer door? Het zit vijf keer in de payload. Ik heb RSC payloads zien opzwellen tot megabytes hierdoor.
De oplossing: Gebruik compositie. Geef Server Components als children door in plaats van data als props:
// GOED: Server Components fetchen hun eigen data, stromen door als children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetcht eigen data */}
<Settings /> {/* Server Component — fetcht eigen data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetcht eigen data */}
</ClientWidget>
</ClientShell>
);
}Fout 3: Suspense Niet Gebruiken#
Zonder Suspense wordt de Time to First Byte (TTFB) van je pagina gelimiteerd door je langzaamste data fetch. Ik had een dashboard-pagina die 4 seconden nodig had om te laden omdat één analyticsquery traag was, terwijl de rest van de paginadata in 200ms klaar was.
// SLECHT: alles wacht op alles
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blokkeert alles
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOED: analytics laadt onafhankelijk
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Duurt 4s maar blokkeert de rest niet */}
</Suspense>
</div>
);
}Fout 4: Serialisatiefouten bij Runtime#
Deze is bijzonder pijnlijk omdat je het vaak pas in productie opmerkt. Je geeft iets niet-serialiseerbaars door over de boundary en krijgt een cryptische foutmelding:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
Veelvoorkomende boosdoeners:
- Date-objecten doorgeven (gebruik
.toISOString()in plaats daarvan) - Map of Set doorgeven (converteer naar arrays/objecten)
- Klasse-instanties van ORM's doorgeven (gebruik
.toJSON()of spread naar platte objecten) - Functies doorgeven (verplaats de logica naar het Client Component of gebruik Server Actions)
- Prisma modelresultaten met
Decimalvelden doorgeven (converteer naarnumberofstring)
// SLECHT
const user = await prisma.user.findUnique({ where: { id } });
// user kan niet-serialiseerbare velden bevatten (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOED
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;Fout 5: Context Overal Voor Gebruiken#
useContext werkt alleen in Client Components. Als je probeert React context te gebruiken in een Server Component, werkt het niet. Ik heb mensen hun hele app een Client Component zien maken alleen voor een thema-context.
De oplossing: Gebruik voor thema's en andere globale state CSS-variabelen die server-side worden ingesteld, of gebruik de cookies() / headers() functies:
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}Voor daadwerkelijk client-side state (auth tokens, winkelwagens, realtime data), maak een dun Client Component provider op het juiste niveau — niet de root:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart moet binnen CartProvider gebruikt worden");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is een Client Component, maar children stromen door als server content
return <CartProvider>{children}</CartProvider>;
}Fout 6: De Bundelgrootte-Impact Negeren#
Een van de grootste winsten van RSC is dat Server Component-code nooit naar de client wordt gestuurd. Maar je moet hier actief over nadenken. Als je een component hebt dat een markdown-parser van 50KB gebruikt en alleen gerenderde content toont — dat zou een Server Component moeten zijn. De parser blijft op de server, en alleen de HTML-output gaat naar de client.
// Server Component — marked blijft op de server
import { marked } from "marked"; // 50KB bibliotheek — wordt nooit naar de client gestuurd
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Als je dit een Client Component had gemaakt, zou marked naar de browser worden gestuurd. Voor niets. De gebruiker zou 50KB JavaScript downloaden alleen om content te renderen die gewoon HTML had kunnen zijn.
Controleer je bundel met @next/bundle-analyzer. De resultaten zouden je kunnen verrassen.
Caching Strategie#
Caching in Next.js 15+ is aanzienlijk vereenvoudigd vergeleken met eerdere versies, maar er zijn nog steeds aparte lagen om te begrijpen.
De cache() Functie (React)#
React's cache() is voor per-request deduplicatie, niet voor persistente caching:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Roep dit overal in je componentboom aan tijdens een enkel verzoek.
// Er wordt maar één daadwerkelijke database-query uitgevoerd.Dit is beperkt tot een enkel serververzoek. Wanneer het verzoek klaar is, is de gecachte waarde weg. Het is memoization, geen caching.
unstable_cache (Next.js)#
Voor persistente caching over verzoeken heen, gebruik unstable_cache (de naam is al eeuwig "unstable", maar het werkt prima in productie):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // hervalideer elk uur
tags: ["products"], // voor on-demand hervalidatie
}
);
// Gebruik in een Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Om te invalideren:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Statisch vs Dynamisch Renderen#
Next.js bepaalt of een route statisch of dynamisch is op basis van wat je erin gebruikt:
Statisch (gerenderd bij build time, gecacht):
- Geen dynamische functies (
cookies(),headers(),searchParams) - Alle
fetch-calls hebben caching ingeschakeld - Geen
export const dynamic = "force-dynamic"
Dynamisch (gerenderd per verzoek):
- Gebruikt
cookies(),headers(), ofsearchParams - Gebruikt
fetchmetcache: "no-store" - Heeft
export const dynamic = "force-dynamic" - Gebruikt
connection()ofafter()vannext/server
Je kunt controleren welke routes statisch vs dynamisch zijn door next build uit te voeren — het toont een legenda onderaan:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Statisch ● SSG λ Dynamisch
De Caching Hiërarchie#
Denk aan caching in lagen:
1. React cache() — per-verzoek, in-memory, automatische dedup
2. fetch() cache — over verzoeken heen, automatisch voor GET-verzoeken
3. unstable_cache() — over verzoeken heen, voor non-fetch operaties
4. Full Route Cache — gerenderde HTML gecacht bij build/hervalidatie
5. Router Cache (client) — in-browser cache van bezochte routes
Elke laag dient een ander doel. Je hebt niet altijd allemaal nodig, maar begrijpen welke actief is helpt bij het debuggen van "waarom wordt mijn data niet bijgewerkt?" problemen.
Een Caching Strategie uit de Praktijk#
Dit is wat ik daadwerkelijk doe in productie:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-verzoek dedup: roep dit meerdere keren aan in één render,
// er draait maar één DB-query
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-verzoek cache: resultaten persisteren over verzoeken heen,
// hervalideer elke 5 minuten of on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// Geen caching: altijd vers (voor gebruikersspecifieke data)
export const getUserCart = cache(async (userId: string) => {
// cache() hier is alleen voor per-verzoek dedup, niet voor persistentie
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});De vuistregel: publieke data die weinig verandert krijgt unstable_cache. Gebruikersspecifieke data krijgt cache() alleen voor dedup. Realtime data krijgt helemaal geen caching.
Server Actions: De Brug Terug#
Server Actions verdienen hun eigen sectie omdat ze het RSC-verhaal compleet maken. Ze zijn hoe Client Components terug communiceren naar de server zonder API-routes.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Ongeldig e-mailadres" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Al geabonneerd" };
}
return { error: "Er ging iets mis" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="jij@voorbeeld.nl"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Abonneren..." : "Abonneren"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Geabonneerd!</p>}
</form>
);
}Server Actions zijn het antwoord op "hoe muteer ik data?" in de RSC-wereld. Ze vervangen de meeste API-routes voor formulierverzendingen, mutaties en bijwerkingen.
Belangrijke regels voor Server Actions:
- Valideer altijd invoer (de functie is aanroepbaar vanuit de client — behandel het als een API-endpoint)
- Retourneer altijd serialiseerbare data
- Ze draaien op de server, dus je kunt databases, bestandssystemen en secrets benaderen
- Ze kunnen
revalidatePath()ofrevalidateTag()aanroepen om gecachte data bij te werken na mutaties
Migratiepatronen#
Als je een bestaande React-app hebt (Pages Router, Create React App, Vite), hoeft de overstap naar RSC geen complete herschrijving te zijn. Dit is hoe ik het aanpak.
Stap 1: Breng Je Componenten in Kaart#
Loop je componentboom door en classificeer alles:
Component State? Effects? Events? → Beslissing
─────────────────────────────────────────────────────────
Header Nee Nee Nee → Server
NavigationMenu Nee Nee Ja → Client (mobiele toggle)
Footer Nee Nee Nee → Server
BlogPost Nee Nee Nee → Server
SearchBar Ja Ja Ja → Client
ProductCard Nee Nee Ja → Client (onClick) of splitsen
UserAvatar Nee Nee Nee → Server
CommentForm Ja Ja Ja → Client
Sidebar Ja Nee Ja → Client (inklaptoggle)
MarkdownRenderer Nee Nee Nee → Server (grote dependency-winst)
DataTable Ja Ja Ja → Client (sorteren, filteren)
Stap 2: Verplaats Data Fetching Omhoog#
De grootste architectuurwijziging is het verplaatsen van data fetching van useEffect in componenten naar async Server Components. Dit is waar het echte migratiewerk zit.
Voor:
// Oud patroon — data fetching in een Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}Na:
// Nieuw patroon — Server Component fetcht, Client Component interageert
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}Geen loading state management. Geen error state. Geen useEffect. Het framework handelt dat allemaal af via Suspense en error boundaries.
Stap 3: Splits Componenten bij Interactieboundaries#
Veel componenten zijn grotendeels statisch met een klein interactief deel. Splits ze:
Voor (één groot Client Component):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}Na (Server Component met een klein Client-eilandje):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Verwijder uit favorieten" : "Toevoegen aan favorieten"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}De afbeelding, titel, beschrijving en prijs zijn nu server-gerenderd. Alleen de kleine favorietenknop is een Client Component. Minder JavaScript, snellere paginalading.
Stap 4: Converteer API-Routes naar Server Actions#
Als je API-routes hebt die uitsluitend je eigen frontend bedienen (niet externe consumenten), kunnen de meeste daarvan Server Actions worden:
Voor:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// valideren, opslaan in DB, e-mail sturen
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});Na:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// valideren, opslaan in DB, e-mail sturen
return { success: true };
}
// Client component — roep de functie gewoon direct aan
import { submitContactForm } from "@/app/actions/contact";Behoud API-routes voor: webhooks, externe API-consumenten, alles dat custom HTTP-headers of statuscodes nodig heeft, bestandsuploads met streaming.
Stap 5: RSC Components Testen#
Het testen van Server Components vereist een iets andere aanpak omdat ze async kunnen zijn:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock de database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("rendert productdetails", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Testproduct",
description: "Een geweldig product",
price: 29.99,
});
// Server Components zijn async — await de JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Testproduct")).toBeInTheDocument();
expect(screen.getByText("Een geweldig product")).toBeInTheDocument();
});
});Het belangrijkste verschil: je await de componentfunctie omdat het async is. Vervolgens render je de resulterende JSX. Al het andere werkt hetzelfde als traditionele React Testing Library.
Bestandsstructuur voor RSC-Projecten#
Hier is de structuur waar ik na meerdere projecten op ben uitgekomen. Het is eigenwijs, maar het werkt:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Homepagina (Server Component)
│ ├── (marketing)/ ← Routegroep voor marketingpagina's
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Routegroep voor geauthenticeerde app
│ │ ├── layout.tsx ← App-shell met auth-check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback voor deze route
│ │ │ └── error.tsx ← Error boundary voor deze route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Gedeelde UI-primitieven (meestal Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (alleen styling)
│ └── features/ ← Feature-specifieke componenten
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetcht data)
│ │ ├── ProductCard.tsx ← Server (presentationeel)
│ │ ├── ProductSearch.tsx ← Client (useState, invoer)
│ │ └── AddToCart.tsx ← Client (onClick, mutatie)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetcht data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (formulier, realtime)
├── lib/
│ ├── data/ ← Data-toegangslaag
│ │ ├── products.ts ← cache() gewrapte DB-queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — wrapt delen die thema nodig hebben
└── CartProvider.tsx ← "use client" — wrapt alleen de shop-sectie
De kernprincipes:
- Server Components hebben geen directive — ze zijn de default
- Client Components zijn expliciet gemarkeerd — je kunt het in één oogopslag zien
- Data fetching leeft in
lib/data/— gewrapt metcache()ofunstable_cache - Server Actions leven in
app/actions/— co-located met de app, duidelijk gescheiden - Providers wrappen het minimum noodzakelijke — niet de hele app
De Conclusie#
React Server Components zijn niet zomaar een nieuwe API. Ze zijn een andere manier van denken over waar code draait, waar data leeft, en hoe de stukken met elkaar verbinden. De verschuiving in mentaal model is echt en kost tijd.
Maar zodra het klikt — zodra je stopt met vechten tegen de boundary en begint te ontwerpen eromheen — eindig je met apps die sneller, eenvoudiger en beter onderhoudbaar zijn dan wat we eerder bouwden. Minder JavaScript wordt naar de client gestuurd. Data fetching vereist geen ceremonie. De componentboom wordt de architectuur.
De transitie is het waard. Weet alleen dat de eerste paar projecten oncomfortabel zullen voelen, en dat dat normaal is. Je worstelt niet omdat RSC slecht is. Je worstelt omdat het oprecht nieuw is.
Begin met Server Components overal. Duw "use client" naar de bladeren. Wrap trage dingen in Suspense. Fetch data waar het gerenderd wordt. Componeer via children.
Dat is het hele draaiboek. Al het andere zijn details.