Introductie: Waarom Data Ophalen Helemaal Is Veranderd
Als je eerder met de Pages Router hebt gewerkt, weet je hoe het ging: getServerSideProps, getStaticProps, getInitialProps... je haalde data op in een aparte functie en gaf die als props door aan je component. Het werkte, maar het voelde altijd een beetje omslachtig.
Met de App Router is dat verhaal compleet anders. Next.js draait het model om: server-first is nu de standaard. En eerlijk gezegd? Dat is een enorme verbetering.
Componenten zijn standaard Server Components. Ze draaien op de server, hebben directe toegang tot databases, bestandssystemen en API's, en versturen geen enkele byte JavaScript naar de client. Dat klinkt misschien als een klein detail, maar de impact op prestaties en beveiliging is enorm.
In dit artikel nemen we je mee door alle aspecten van data ophalen in de Next.js App Router (Next.js 16). Van React Server Components en parallelle requests tot streaming met Suspense, de Data Access Layer, caching met use cache, ISR, Partial Prerendering en de fouten die je absoluut wilt vermijden. Laten we erin duiken.
React Server Components: De Basis van Data Ophalen
React Server Components (RSC's) vormen de kern van hoe data ophalen werkt in de App Router. Een Server Component is simpelweg een async component die op de server draait en nooit als JavaScript naar de browser wordt gestuurd. Je kunt er dus gewoon rechtstreeks data in ophalen — geen aparte functie nodig.
Data Ophalen met de Fetch API
De meest straightforward manier om data op te halen is met de standaard fetch API. Next.js breidt die uit met extra opties voor caching en revalidatie, wat best handig is.
// app/producten/page.tsx
interface Product {
id: string;
naam: string;
prijs: number;
beschrijving: string;
categorie: string;
}
export default async function ProductenPage() {
// fetch runs on the server — no client-side JavaScript shipped
const response = await fetch('https://api.example.com/producten', {
next: { tags: ['producten'] },
});
if (!response.ok) {
throw new Error('Kon producten niet ophalen');
}
const producten: Product[] = await response.json();
return (
<main>
<h1>Onze Producten</h1>
<div className="grid grid-cols-3 gap-6">
{producten.map((product) => (
<article key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-bold">{product.naam}</h2>
<p className="text-gray-600">{product.beschrijving}</p>
<span className="text-lg font-semibold">
€{product.prijs.toFixed(2)}
</span>
</article>
))}
</div>
</main>
);
}
Directe Database-toegang met een ORM
Omdat Server Components op de server draaien, kun je ook gewoon direct je database aanspreken met een ORM als Prisma of Drizzle. Geen tussenliggende API-laag nodig voor simpele queries — dat scheelt een hoop boilerplate.
// app/blog/page.tsx
import { db } from '@/lib/db';
import { posts, users } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
interface BlogPost {
id: string;
titel: string;
inhoud: string;
gepubliceerdOp: Date;
auteur: {
naam: string;
avatar: string;
};
}
export default async function BlogPage() {
// Direct database query using Drizzle ORM — no API layer needed
const blogPosts = await db
.select({
id: posts.id,
titel: posts.titel,
inhoud: posts.inhoud,
gepubliceerdOp: posts.gepubliceerdOp,
auteurNaam: users.naam,
auteurAvatar: users.avatar,
})
.from(posts)
.innerJoin(users, eq(posts.auteurId, users.id))
.orderBy(desc(posts.gepubliceerdOp))
.limit(20);
return (
<main>
<h1>Blog</h1>
{blogPosts.map((post) => (
<article key={post.id} className="mb-8">
<h2>{post.titel}</h2>
<div className="flex items-center gap-2 text-sm text-gray-500">
<img
src={post.auteurAvatar}
alt={post.auteurNaam}
className="w-6 h-6 rounded-full"
/>
<span>{post.auteurNaam}</span>
<time>{post.gepubliceerdOp.toLocaleDateString('nl-NL')}</time>
</div>
<p>{post.inhoud.slice(0, 200)}...</p>
</article>
))}
</main>
);
}
Het mooie hiervan is dat niets van deze code naar de client wordt gestuurd. De database-connectiestring, de ORM-imports, de query-logica — het blijft allemaal op de server. De client ontvangt alleen de gerenderde HTML en het minimale React-payload voor hydration van eventuele Client Components.
Dit levert drie grote voordelen op: snellere initiële laadtijden (minder JavaScript naar de client), betere beveiliging (gevoelige logica en credentials blijven op de server), en een simpeler mentaal model (data ophalen en renderen in dezelfde component). Best een fijne deal als je het mij vraagt.
Parallelle Data Requests: Watervallen Voorkomen
Oké, dit is een belangrijk onderwerp. Een van de meest voorkomende prestatieproblemen bij data ophalen zijn watervallen: sequentiële requests waarbij de ene wacht op de andere voordat die kan starten. In Server Components is dit verrassend makkelijk om per ongeluk te veroorzaken met meerdere await-statements achter elkaar.
Het Probleem: Sequentieel Ophalen
Kijk eens naar dit voorbeeld. Drie onafhankelijke datasets worden na elkaar opgehaald:
// app/dashboard/page.tsx — SLECHT: sequential waterfall
export default async function DashboardPage() {
// Each request waits for the previous one to complete
const gebruiker = await fetchGebruiker(); // ~200ms
const bestellingen = await fetchBestellingen(); // ~300ms (starts after 200ms)
const statistieken = await fetchStatistieken(); // ~150ms (starts after 500ms)
// Total: ~650ms
return (
<main>
<GebruikerProfiel data={gebruiker} />
<BestellingenLijst data={bestellingen} />
<StatistiekenPanel data={statistieken} />
</main>
);
}
De totale laadtijd is hier de som van alle requests: 650ms. Maar deze drie requests hebben helemaal niets met elkaar te maken!
De Oplossing: Promise.all() en Promise.allSettled()
Door Promise.all() te gebruiken, starten alle requests tegelijk:
// app/dashboard/page.tsx — GOED: parallel requests
export default async function DashboardPage() {
// All requests start simultaneously
const [gebruiker, bestellingen, statistieken] = await Promise.all([
fetchGebruiker(), // ~200ms
fetchBestellingen(), // ~300ms (starts immediately)
fetchStatistieken(), // ~150ms (starts immediately)
]);
// Total: ~300ms (only as slow as the slowest request)
return (
<main>
<GebruikerProfiel data={gebruiker} />
<BestellingenLijst data={bestellingen} />
<StatistiekenPanel data={statistieken} />
</main>
);
}
De totale laadtijd is nu slechts 300ms — de duur van het langzaamste request. Meer dan een halvering. Dat is nogal een verschil voor zo'n simpele wijziging.
Gebruik Promise.allSettled() wanneer je niet wilt dat één falend request de hele pagina laat mislukken. Dit retourneert een array met het resultaat van elke promise (inclusief eventuele fouten), zodat je per sectie kunt beslissen wat je toont.
Automatisch Parallellisme in Layouts en Pages
Nog een leuk detail: de App Router haalt data in layouts en pages automatisch parallel op. Als je layout.tsx en page.tsx allebei data ophalen, wacht de page niet op de layout. Next.js start beide renders tegelijkertijd. Gratis prestatievoordeel — je hoeft er niets voor te doen.
Streaming en React Suspense
Streaming is eerlijk gezegd een van de krachtigste features van de App Router. In plaats van te wachten tot álle data beschikbaar is voordat er iets naar de browser gaat, rendert Next.js de pagina progressief: eerst de statische shell, daarna de dynamische delen zodra hun data er is.
Hoe Streaming Werkt
Next.js gebruikt HTTP chunked transfer encoding om delen van de pagina stuk voor stuk naar de browser te sturen. De browser kan al beginnen met renderen terwijl de server nog bezig is met data ophalen voor andere secties. Dit verbetert drie cruciale metrics:
- TTFB (Time to First Byte): De browser ontvangt sneller de eerste bytes omdat de server niet hoeft te wachten tot alles klaar is.
- FCP (First Contentful Paint): De gebruiker ziet sneller inhoud op het scherm — de statische delen verschijnen direct.
- TTI (Time to Interactive): Interactieve elementen worden sneller bruikbaar omdat ze niet geblokkeerd worden door langzame data-requests.
Suspense Boundaries en loading.tsx
Er zijn twee manieren om streaming te gebruiken: het speciale loading.tsx-bestand (dat automatisch een Suspense boundary om je page wrapt) en handmatige <Suspense>-boundaries voor meer controle. Laten we eens naar een concreet voorbeeld kijken.
// app/dashboard/page.tsx — Streaming dashboard with Suspense
import { Suspense } from 'react';
// Skeleton components for loading states
function StatistiekenSkeleton() {
return <div className="h-32 bg-gray-200 animate-pulse rounded-lg" />;
}
function BestellingenSkeleton() {
return (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 animate-pulse rounded" />
))}
</div>
);
}
function AanbevelingenSkeleton() {
return <div className="h-48 bg-gray-200 animate-pulse rounded-lg" />;
}
// Async Server Components that fetch their own data
async function Statistieken() {
const stats = await fetchStatistieken(); // ~150ms
return (
<div className="grid grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<h3>Omzet</h3>
<p className="text-2xl font-bold">€{stats.omzet.toLocaleString()}</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<h3>Bestellingen</h3>
<p className="text-2xl font-bold">{stats.aantalBestellingen}</p>
</div>
</div>
);
}
async function RecenteBestellingen() {
const bestellingen = await fetchRecenteBestellingen(); // ~400ms
return (
<ul className="divide-y">
{bestellingen.map((b) => (
<li key={b.id} className="py-3 flex justify-between">
<span>{b.klantNaam}</span>
<span>€{b.totaal.toFixed(2)}</span>
</li>
))}
</ul>
);
}
async function Aanbevelingen() {
const items = await fetchAanbevelingen(); // ~800ms — slowest request
return (
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<div key={item.id} className="p-3 border rounded">{item.naam}</div>
))}
</div>
);
}
// Main page component — shell renders immediately
export default function DashboardPage() {
return (
<main className="space-y-8 p-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
{/* Each section streams independently */}
<Suspense fallback={<StatistiekenSkeleton />}>
<Statistieken />
</Suspense>
<section>
<h2 className="text-xl font-semibold mb-4">Recente Bestellingen</h2>
<Suspense fallback={<BestellingenSkeleton />}>
<RecenteBestellingen />
</Suspense>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Aanbevolen voor jou</h2>
<Suspense fallback={<AanbevelingenSkeleton />}>
<Aanbevelingen />
</Suspense>
</section>
</main>
);
}
In dit voorbeeld rendert de pagina in stappen: eerst de statische shell met titel en skeletons (direct zichtbaar), dan na ~150ms het statistiekenblok, na ~400ms de bestellingen, en na ~800ms de aanbevelingen. De gebruiker ziet geen lege pagina maar een dashboard dat progressief opbouwt. Dat voelt gewoon veel beter.
Let op dat de hoofdcomponent DashboardPage zelf niet async is. Dat is belangrijk: als de pagina zelf await zou doen, blokkeert de hele pagina. Door elke datasectie in een aparte async Server Component te zetten en die in een Suspense boundary te wrappen, streamt elke sectie onafhankelijk.
De Data Access Layer (DAL)
Naarmate je applicatie groeit, wordt het echt belangrijk om je data-ophaallogica te centraliseren. Het Next.js-team raadt hiervoor een Data Access Layer (DAL) aan, en na er zelf mee te hebben gewerkt snap ik waarom. De DAL fungeert als de enige bron van waarheid voor al je data-interacties: autorisatiecontroles, DTO-transformaties en beveiligingslogica zitten allemaal op één plek.
Waarom een DAL?
Een paar goede redenen:
- Beveiliging: Alle autorisatiecontroles op een centrale plek. Je kunt nooit per ongeluk een onbeveiligde query schrijven.
- Herbruikbaarheid: Dezelfde data-ophaalfuncties in pages, layouts, server actions en API routes.
- DTO-patroon: Alleen de benodigde velden doorgeven aan componenten, niet het volledige database-model. Voorkomt dat gevoelige data per ongeluk lekt.
- Testbaarheid: Een duidelijke interface die je eenvoudig kunt mocken in tests.
Het server-only Pakket
Het server-only-pakket is een cruciaal onderdeel van je DAL. Door dit bovenaan je DAL-bestanden te importeren, genereert Next.js een build-time fout als iemand per ongeluk probeert deze code in een Client Component te gebruiken. Dat is een fijne vangnet.
Een Compleet DAL-voorbeeld
// lib/dal/producten.ts
import 'server-only';
import { db } from '@/lib/db';
import { producten, categorieen } from '@/lib/db/schema';
import { eq, desc, and, gte } from 'drizzle-orm';
import { getGebruikerSessie } from '@/lib/auth';
import { cache } from 'react';
import { experimental_taintObjectReference as taintObjectReference } from 'react';
// DTO types — only expose what the UI needs
export interface ProductDTO {
id: string;
naam: string;
slug: string;
prijs: number;
beschrijving: string;
afbeeldingUrl: string;
categorieNaam: string;
opVoorraad: boolean;
}
export interface ProductDetailDTO extends ProductDTO {
specificaties: Record<string, string>;
gemiddeldeBeoordeling: number;
aantalBeoordelingen: number;
}
// Authorization helper
async function verifieerToegang(vereistRol?: string): Promise<string> {
const sessie = await getGebruikerSessie();
if (!sessie) {
throw new Error('Niet geauthenticeerd');
}
if (vereistRol && sessie.rol !== vereistRol) {
throw new Error('Onvoldoende rechten');
}
return sessie.gebruikerId;
}
// Use React.cache to deduplicate requests within a single render
export const getProducten = cache(
async (categorieSlug?: string): Promise<ProductDTO[]> => {
const rijen = await db
.select({
id: producten.id,
naam: producten.naam,
slug: producten.slug,
prijs: producten.prijs,
beschrijving: producten.beschrijving,
afbeeldingUrl: producten.afbeeldingUrl,
categorieNaam: categorieen.naam,
voorraad: producten.voorraad,
})
.from(producten)
.innerJoin(categorieen, eq(producten.categorieId, categorieen.id))
.where(
categorieSlug
? eq(categorieen.slug, categorieSlug)
: undefined
)
.orderBy(desc(producten.aangemaakt));
// Transform to DTO — strip internal fields, compute derived values
return rijen.map((rij) => ({
id: rij.id,
naam: rij.naam,
slug: rij.slug,
prijs: rij.prijs,
beschrijving: rij.beschrijving,
afbeeldingUrl: rij.afbeeldingUrl,
categorieNaam: rij.categorieNaam,
opVoorraad: rij.voorraad > 0,
}));
}
);
export const getProductDetail = cache(
async (slug: string): Promise<ProductDetailDTO | null> => {
const rij = await db.query.producten.findFirst({
where: eq(producten.slug, slug),
with: {
categorie: true,
beoordelingen: true,
specificaties: true,
},
});
if (!rij) return null;
const dto: ProductDetailDTO = {
id: rij.id,
naam: rij.naam,
slug: rij.slug,
prijs: rij.prijs,
beschrijving: rij.beschrijving,
afbeeldingUrl: rij.afbeeldingUrl,
categorieNaam: rij.categorie.naam,
opVoorraad: rij.voorraad > 0,
specificaties: Object.fromEntries(
rij.specificaties.map((s) => [s.sleutel, s.waarde])
),
gemiddeldeBeoordeling:
rij.beoordelingen.reduce((sum, b) => sum + b.score, 0) /
(rij.beoordelingen.length || 1),
aantalBeoordelingen: rij.beoordelingen.length,
};
// Taint the raw database object to prevent accidental exposure
taintObjectReference(
'Geef het ruwe database-object niet door aan een Client Component.',
rij
);
return dto;
}
);
// Admin-only function with authorization check
export async function verwijderProduct(productId: string): Promise<void> {
await verifieerToegang('admin');
await db.delete(producten).where(eq(producten.id, productId));
}
Even de belangrijkste patronen uitlichten:
import 'server-only'bovenaan zorgt ervoor dat dit bestand nooit in een Client Component terechtkomt.React.cache()deduplicieert requests: als dezelfde functie meerdere keren wordt aangeroepen tijdens een render (bijvoorbeeld in een layout én een page), wordt de database-query maar één keer uitgevoerd.- De
taintObjectReferenceAPI markeert het ruwe database-object zodat React een fout gooit als je het per ongeluk als prop doorgeeft aan een Client Component. Erg handig als extra beveiligingslaag. - DTO-types bevatten alleen de velden die de UI nodig heeft — geen interne velden als timestamps of foreign keys.
- Autorisatiecontroles zitten in de DAL zelf, niet in de componenten. Zo kun je nooit per ongeluk een onbeveiligde route aanmaken.
Caching met "use cache"
Next.js 16 introduceert een compleet nieuw caching-model, en eerlijk gezegd was dat hard nodig. Het oude impliciete caching-gedrag (waarbij fetch-requests automatisch gecacht werden) was best verwarrend. Je begreep niet altijd waarom je data niet up-to-date was.
Het nieuwe model is opt-in: niets wordt gecacht tenzij je dat expliciet aangeeft met de "use cache"-directive.
use cache op Verschillende Niveaus
Je kunt "use cache" op drie niveaus toepassen:
- Paginaniveau: Cache een hele pagina-render.
- Componentniveau: Cache individuele componenten onafhankelijk.
- Functieniveau: Cache het resultaat van individuele data-ophaelfuncties.
// 1. Page-level caching
// app/blog/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
export default async function BlogPage() {
'use cache';
cacheLife('hours'); // Built-in profile: cache for 1 hour
cacheTag('blog-overzicht'); // Tag for targeted invalidation
const posts = await fetchBlogPosts();
return (
<main>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.titel}</h2>
<p>{post.samenvatting}</p>
</article>
))}
</main>
);
}
// 2. Component-level caching
async function PopulaireArtikelen() {
'use cache';
cacheLife('days');
cacheTag('populair');
const artikelen = await fetchPopulaireArtikelen();
return (
<aside>
<h3>Populair</h3>
<ul>
{artikelen.map((a) => (
<li key={a.id}>{a.titel}</li>
))}
</ul>
</aside>
);
}
// 3. Function-level caching
async function fetchProductPrijzen(categorieId: string) {
'use cache';
cacheLife({
stale: 300, // Serve stale for 5 minutes
revalidate: 60, // Revalidate in background every 60 seconds
expire: 3600, // Hard expiry after 1 hour
});
cacheTag(`prijzen-${categorieId}`);
const response = await fetch(
`https://api.example.com/prijzen?categorie=${categorieId}`
);
return response.json();
}
Ingebouwde Cache-profielen
Next.js biedt een aantal handige ingebouwde profielen voor cacheLife:
'seconds'— Cache voor enkele seconden. Voor snel veranderende data.'minutes'— Cache voor enkele minuten.'hours'— Cache voor een uur. Goed voor blogposts en productlijsten.'days'— Cache voor een dag. Voor relatief statische content.'weeks'— Cache voor een week. Voor zeer stabiele content.'max'— Cache zo lang mogelijk. Voor volledig statische content.
Naast strings kun je ook een object meegeven met stale, revalidate en expire voor meer controle. En je kunt eigen profielen definieren in next.config.ts:
// next.config.ts — Custom cache profiles and PPR configuration
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enable Partial Prerendering
cacheComponents: true,
// Define custom cache profiles
cacheLife: {
// Custom profile for product data
productData: {
stale: 300, // Serve stale for 5 minutes
revalidate: 60, // Start background revalidation after 1 minute
expire: 3600, // Hard expire after 1 hour
},
// Custom profile for user-generated content
ugc: {
stale: 60, // Serve stale for 1 minute
revalidate: 30, // Revalidate every 30 seconds
expire: 600, // Expire after 10 minutes
},
// Custom profile for near-static content like legal pages
semiStatisch: {
stale: 86400, // Serve stale for 1 day
revalidate: 3600, // Revalidate every hour
expire: 604800, // Expire after 1 week
},
},
};
export default nextConfig;
Met deze configuratie verwijs je in je componenten naar je eigen profielen via cacheLife('productData') of cacheLife('semiStatisch'). Zo hanteer je een consistente cachestrategie door je hele app heen.
Varianten: use cache: remote en use cache: private
Naast de standaard "use cache" bestaan er twee extra varianten die het vermelden waard zijn:
"use cache: remote"— Slaat cache-entries op in een gedeelde, externe cache (denk aan Redis-achtige opslag bij Vercel of een custom CDN-cache). Handig als je meerdere serverinstanties hebt die dezelfde cache moeten delen."use cache: private"— Slaat cache-entries uitsluitend lokaal op bij de huidige serverinstantie. Geschikt voor gebruikersspecifieke data of als je niet wilt dat gecachte data gedeeld wordt.
ISR en On-Demand Revalidatie
Incremental Static Regeneration (ISR) laat je statisch gegenereerde pagina's op de achtergrond vernieuwen zonder een volledige rebuild. In de App Router werkt ISR via de revalidate-configuratie in combinatie met "use cache".
Tijdgebaseerde Revalidatie
De simpelste vorm van ISR is tijdgebaseerd: na een bepaalde periode wordt de pagina op de achtergrond opnieuw gegenereerd bij het volgende verzoek. Dit volgt het stale-while-revalidate-patroon — de huidige (mogelijk verouderde) versie wordt direct geserveerd terwijl de nieuwe op de achtergrond wordt aangemaakt.
Met "use cache" stel je dit in via cacheLife. De stale-waarde bepaalt hoe lang de cache als vers geldt, revalidate wanneer de achtergrondregeneratie start, en expire wanneer de cache-entry definitief verloopt.
On-Demand Revalidatie
Tijdgebaseerde revalidatie is lang niet altijd genoeg. Wanneer content in een CMS wordt bijgewerkt, wil je de wijziging direct beschikbaar maken — niet pas na afloop van een timer. Hiervoor biedt Next.js twee functies: revalidatePath() en revalidateTag().
// app/api/revalideer/route.ts — Webhook endpoint for CMS revalidation
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag, revalidatePath } from 'next/cache';
// Secret token to verify webhook authenticity
const WEBHOOK_GEHEIM = process.env.REVALIDATIE_GEHEIM!;
interface WebhookPayload {
type: 'product' | 'blogpost' | 'categorie';
actie: 'aangemaakt' | 'bijgewerkt' | 'verwijderd';
slug?: string;
id: string;
}
export async function POST(request: NextRequest) {
// Verify the webhook signature
const token = request.headers.get('x-webhook-geheim');
if (token !== WEBHOOK_GEHEIM) {
return NextResponse.json({ fout: 'Ongeldig token' }, { status: 401 });
}
const payload: WebhookPayload = await request.json();
switch (payload.type) {
case 'product':
// Invalidate the specific product page and the product list
revalidateTag('producten');
if (payload.slug) {
revalidatePath(`/producten/${payload.slug}`);
}
break;
case 'blogpost':
// Invalidate blog overview and specific post
revalidateTag('blog-overzicht');
if (payload.slug) {
revalidateTag(`blog-post-${payload.slug}`);
revalidatePath(`/blog/${payload.slug}`);
}
break;
case 'categorie':
// Invalidate all product listings (they depend on categories)
revalidateTag('producten');
revalidateTag('categorieen');
revalidatePath('/producten');
break;
}
return NextResponse.json({
gerevalideerd: true,
tijdstip: new Date().toISOString(),
});
}
Het verschil tussen revalidatePath() en revalidateTag() is belangrijk om te snappen:
revalidatePath('/producten/schoenen')invalideert een specifiek URL-pad. Alle cache-entries die bij die route horen worden verwijderd.revalidateTag('producten')invalideert alle cache-entries die met de tag'producten'zijn gemarkeerd viacacheTag(). Dit is flexibeler omdat je meerdere gerelateerde entries tegelijk kunt invalideren, ongeacht hun pad.
In de praktijk gebruik je tags voor brede invalidatie (alle productpagina's vernieuwen wanneer een categorie wijzigt) en paden voor gerichte invalidatie (een specifieke blogpost). Die combinatie werkt verrassend goed.
Partial Prerendering (PPR)
Oké, dit is misschien wel de meest spannende feature van Next.js op dit moment. Partial Prerendering combineert het beste van statische generatie en dynamische rendering in één enkele route. Het concept is eigenlijk vrij simpel: de statische delen van een pagina worden als een statische shell bij build-time gegenereerd, terwijl dynamische delen via streaming worden ingevuld bij het request.
Hoe PPR Werkt
PPR bouwt voort op "use cache" en <Suspense>. Alles in een "use cache"-component wordt statisch geprerenderd. Alles in een Suspense boundary dat niet gecacht is, wordt dynamisch gestreamed. De statische shell wordt direct vanaf de CDN geserveerd, waarna de dynamische delen worden ingevuld zodra de server ze heeft gerenderd.
PPR Inschakelen
Vanaf Next.js 16 schakel je PPR in via de cacheComponents-instelling in je next.config.ts. Componenten met "use cache" worden dan automatisch geprerenderd als onderdeel van de statische shell, en dynamische componenten in Suspense boundaries fungeren als "gaten" die bij request-time worden ingevuld.
Praktijkvoorbeeld: E-commerce Productpagina
// app/producten/[slug]/page.tsx — PPR e-commerce product page
import { Suspense } from 'react';
import { cacheLife, cacheTag } from 'next/cache';
import { getProductDetail } from '@/lib/dal/producten';
import { notFound } from 'next/navigation';
// Static shell: product info (cached)
async function ProductInfo({ slug }: { slug: string }) {
'use cache';
cacheLife('hours');
cacheTag(`product-${slug}`);
const product = await getProductDetail(slug);
if (!product) notFound();
return (
<div className="grid grid-cols-2 gap-8">
<img
src={product.afbeeldingUrl}
alt={product.naam}
className="rounded-lg"
/>
<div>
<h1 className="text-3xl font-bold">{product.naam}</h1>
<p className="text-gray-600 mt-2">{product.beschrijving}</p>
<p className="text-2xl font-bold mt-4">
€{product.prijs.toFixed(2)}
</p>
<dl className="mt-6 space-y-2">
{Object.entries(product.specificaties).map(([key, val]) => (
<div key={key} className="flex gap-2">
<dt className="font-medium">{key}:</dt>
<dd>{val}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
// Dynamic: real-time stock and pricing (not cached)
async function VoorraadStatus({ productId }: { productId: string }) {
// No 'use cache' — this is dynamic, fetched per request
const voorraad = await fetch(
`https://api.example.com/voorraad/${productId}`,
{ cache: 'no-store' }
);
const data = await voorraad.json();
return (
<div className={`p-3 rounded ${data.beschikbaar ? 'bg-green-100' : 'bg-red-100'}`}>
{data.beschikbaar ? (
<p className="text-green-800">
Op voorraad — nog {data.aantal} beschikbaar
</p>
) : (
<p className="text-red-800">Tijdelijk niet op voorraad</p>
)}
</div>
);
}
// Dynamic: personalized recommendations (not cached)
async function PersoonlijkeAanbevelingen({ productId }: { productId: string }) {
const res = await fetch(
`https://api.example.com/aanbevelingen/${productId}`,
{ cache: 'no-store' }
);
const aanbevelingen = await res.json();
return (
<section className="mt-12">
<h2 className="text-xl font-semibold mb-4">Misschien vind je dit ook leuk</h2>
<div className="grid grid-cols-4 gap-4">
{aanbevelingen.map((item: any) => (
<div key={item.id} className="border rounded p-3">
<img src={item.afbeelding} alt={item.naam} className="rounded" />
<p className="mt-2 font-medium">{item.naam}</p>
<p>€{item.prijs.toFixed(2)}</p>
</div>
))}
</div>
</section>
);
}
// Main page: combines static shell with dynamic holes
export default async function ProductPagina({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<main className="max-w-6xl mx-auto p-6">
{/* Static shell — prerendered at build time via 'use cache' */}
<ProductInfo slug={slug} />
{/* Dynamic hole — streamed at request time */}
<Suspense fallback={<p className="animate-pulse">Voorraad laden...</p>}>
<VoorraadStatus productId={slug} />
</Suspense>
{/* Dynamic hole — streamed at request time */}
<Suspense
fallback={
<div className="mt-12 grid grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-48 bg-gray-200 animate-pulse rounded" />
))}
</div>
}
>
<PersoonlijkeAanbevelingen productId={slug} />
</Suspense>
</main>
);
}
In dit voorbeeld zijn productinformatie en specificaties gecacht met "use cache" en worden als statische shell geserveerd. De voorraadstatus en persoonlijke aanbevelingen zijn dynamisch en worden gestreamed. Het resultaat?
- Razendsnelle initiële laadtijd (de statische shell komt direct van de CDN).
- Altijd actuele voorraadgegevens (dynamisch per request).
- Gepersonaliseerde aanbevelingen (dynamisch op basis van de gebruiker).
- Een soepele ervaring met skeletons als fallback terwijl dynamische content laadt.
PPR lost het eeuwige dilemma op tussen statisch en dynamisch. Je hoeft niet meer te kiezen — binnen een enkele route kun je delen statisch cachen en andere delen dynamisch streamen. Dat is best revolutionair als je erover nadenkt.
Veelgemaakte Fouten en Best Practices
Nu we alle technische mogelijkheden hebben doorgenomen, laten we kijken naar de fouten die ik het vaakst tegenkom (en hoe je ze voorkomt).
Fout 1: Data Ophalen in Client Components
Dit is veruit de meest voorkomende fout. Ontwikkelaars halen data op in Client Components met useEffect en useState, terwijl het op de server had gekund. Dit patroon leidt tot:
- Extra JavaScript dat naar de client wordt gestuurd.
- Een waterval: eerst renderen, dan pas data ophalen.
- Layout shifts wanneer de data binnenkomt en de UI verspringt.
- Meer complexiteit door het beheren van laad- en fouttoestanden.
Best practice: Haal data op in Server Components en geef het als props door aan Client Components die interactiviteit nodig hebben. De Client Component heeft dan meteen de data bij de eerste render.
// FOUT: Data ophalen in een Client Component
'use client';
import { useState, useEffect } from 'react';
export function ProductLijstFout() {
const [producten, setProducten] = useState([]);
const [isLaden, setIsLaden] = useState(true);
useEffect(() => {
// This creates a waterfall: render → fetch → re-render
fetch('/api/producten')
.then((res) => res.json())
.then((data) => {
setProducten(data);
setIsLaden(false);
});
}, []);
if (isLaden) return Laden...
;
return {producten.map((p: any) => - {p.naam}
)}
;
}
// CORRECT: Data ophalen in Server Component, interactiviteit in Client Component
// app/producten/page.tsx (Server Component)
import { ProductLijstInteractief } from './product-lijst';
export default async function ProductenPage() {
// Data is fetched on the server — no client JS needed for this
const producten = await fetchProducten();
// Pass pre-fetched data to the interactive Client Component
return ;
}
// app/producten/product-lijst.tsx (Client Component)
'use client';
interface Product {
id: string;
naam: string;
prijs: number;
}
export function ProductLijstInteractief({ producten }: { producten: Product[] }) {
// Data is immediately available — no loading state needed
const [filter, setFilter] = useState('');
const gefilterd = producten.filter((p) =>
p.naam.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Zoek producten..."
/>
<ul>
{gefilterd.map((p) => (
<li key={p.id}>{p.naam} — €{p.prijs.toFixed(2)}</li>
))}
</ul>
</div>
);
}
Fout 2: Hele Database-objecten als Props Doorgeven
Het doorgeven van complete database-objecten als props van Server naar Client Components is riskant. Alle props worden geserialiseerd en naar de browser gestuurd. Als je een heel database-object doorgeeft, kunnen gevoelige velden (wachtwoord-hashes, interne ID's, tokens) per ongeluk in de client terechtkomen.
Best practice: Gebruik het DTO-patroon in je DAL. Geef alleen de velden door die de Client Component echt nodig heeft. Gebruik taintObjectReference als extra vangnet.
Fout 3: Ontbrekende Suspense Boundaries
Zonder Suspense boundaries blokkeert een langzame data-fetch de hele pagina. De gebruiker ziet een witte pagina totdat alles geladen is. Vooral op dashboards met meerdere onafhankelijke secties is dat een probleem.
Best practice: Wrap elke onafhankelijke datasectie in een eigen Suspense boundary met een betekenisvol skeleton als fallback.
Fout 4: Over-caching
Het is verleidelijk om overal "use cache" neer te zetten voor betere prestaties. Maar over-caching leidt tot verouderde data, inconsistenties en verwarring bij gebruikers die hun eigen wijzigingen niet zien.
Best practice: Cache selectief. Productomschrijvingen en blogposts? Agressief cachen. Winkelwageninformatie en realtime voorraad? Dynamisch houden. Denk er per component over na.
Fout 5: Geen Foutafhandeling
Vergeet je foutafhandeling, dan kan een falende API of database-query je hele pagina laten crashen in productie. Niet ideaal.
Best practice: Gebruik error.tsx-bestanden voor route-level foutgrenzen, try-catch in data-ophaelfuncties, en Promise.allSettled() bij parallelle requests waar partieel falen acceptabel is.
Best Practices Checklist
- Gebruik Server Components als standaard voor data ophalen.
- Bouw een Data Access Layer met
server-onlyvoor alle data-interacties. - Gebruik het DTO-patroon: geef nooit ruwe database-objecten door aan Client Components.
- Haal parallelle data op met
Promise.all()om watervallen te voorkomen. - Wrap onafhankelijke datasecties in Suspense boundaries voor streaming.
- Gebruik
"use cache"bewust: cache statische content, houd dynamische content vers. - Implementeer on-demand revalidatie via webhooks voor CMS-content.
- Gebruik
React.cache()voor deduplicatie van database-queries binnen een render. - Gebruik
cacheTag()voor gestructureerde cache-invalidatie. - Voeg altijd foutgrenzen toe op route- en componentniveau.
- Overweeg PPR voor pagina's met een mix van statische en dynamische content.
- Test je caching-strategie: controleer in de Network-tab of de juiste headers worden ingesteld.
Conclusie
Data ophalen in de Next.js App Router is fundamenteel anders dan wat we gewend waren. De verschuiving naar server-first met React Server Components brengt enorme voordelen: betere prestaties, verbeterde beveiliging en een simpeler ontwikkelervaring. Maar het vraagt wel om een nieuw mentaal model.
Hier zijn de belangrijkste takeaways:
- React Server Components zijn dé standaard voor data ophalen. Ze draaien op de server, hebben directe toegang tot databases en API's, en versturen geen JavaScript naar de client.
- Parallelle requests met
Promise.all()zijn essentieel. Haal onafhankelijke datasets altijd gelijktijdig op. - Streaming met Suspense zorgt voor progressieve paginalading. Gebruikers zien direct inhoud terwijl langzamere secties nog laden.
- De Data Access Layer centraliseert al je data-logica met autorisatie, DTO-transformaties en beveiliging. Het aanbevolen patroon voor serieuze applicaties.
- Het nieuwe caching-model met
"use cache"is expliciet en voorspelbaar. Volledige controle over wat gecacht wordt en voor hoe lang. - ISR en on-demand revalidatie geven je statische snelheid met dynamische versheid.
- Partial Prerendering combineert statische en dynamische content in een enkele route. Geen SSG vs SSR dilemma meer.
Mijn advies voor nieuwe projecten: begin met Server Components voor al je data-ophaallogica, bouw een DAL vanaf dag één, gebruik Suspense boundaries voor elke onafhankelijke datasectie, en voeg caching pas toe wanneer je de prestatiebehoefte ervan hebt vastgesteld. Begin simpel, meet, en optimaliseer waar nodig.
De App Router biedt een krachtig en flexibel systeem voor data ophalen. Door de patronen uit dit artikel toe te passen, bouw je applicaties die snel, veilig, onderhoudbaar en klaar voor de toekomst zijn.