Vorige maand deed ik een performance audit op een Next.js productie-app. De Largest Contentful Paint (LCP) was 4.1 seconden op mobiel. Na een week optimalisatie: 0.8 seconden. Hier zijn de exacte stappen die ik heb genomen.
Stap 1: Images optimaliseren
next/image met priority, sizes en WebP. Van 2.4MB naar 180KB. Impact: -0.9s LCP.
Stap 2: Fonts lokaal hosten
next/font elimineert externe requests. font-display: swap voorkomt FOIT. Impact: -0.6s.
Stap 3: JS bundle verkleinen
Tree-shaking, selectieve imports, verwijder ongebruikte libraries. Van 485KB naar 220KB. Impact: -0.8s.
Stap 4: Server-side caching
unstable_cache voor statische data, dynamisch voor user-specifiek. TTFB van 1.2s naar 0.4s. Impact: -0.6s.
Stap 5: Streaming met Suspense
Suspense boundaries voor progressieve rendering. Gebruiker ziet direct content. Impact: -0.4s.
De uitgangssituatie
De app was een dashboard met ~15 pagina's, gebouwd met Next.js 16 en Tailwind CSS. De Core Web Vitals waren:
| Metric | Waarde | Beoordeling |
|---|---|---|
| LCP | 4.1s | Slecht |
| FID | 180ms | Matig |
| CLS | 0.24 | Slecht |
| TTFB | 1.2s | Matig |
Meten voor je optimaliseert
Optimaliseer nooit op basis van aannames. Meet eerst met Lighthouse, WebPageTest of de Chrome DevTools Performance tab. Mijn eerste instinct was "de bundel is te groot" — maar het bleek dat images het grootste probleem waren.
Stap 1: Image optimalisatie (-0.9s)
De homepage had een hero image van 2.4MB als PNG. De eerste fix was de makkelijkste:
// ❌ Voorheen
<img src="/hero.png" alt="Dashboard preview" />
// ✅ Na optimalisatie
<Image
src="/hero.png"
alt="Dashboard preview"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 80vw"
/>De priority prop voorkomt lazy loading voor above-the-fold images. De sizes prop zorgt ervoor dat Next.js de juiste afbeeldingsgrootte stuurt per viewport.
Resultaat: 2.4MB → 180KB WebP. LCP van 4.1s naar 3.2s.
Stap 2: Font optimalisatie (-0.6s)
De app laadde drie fonts via Google Fonts — elk met een render-blocking request. De fix:
// next.config.ts
import { Inter, DM_Serif_Display, JetBrains_Mono } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
const dmSerif = DM_Serif_Display({ weight: '400', subsets: ['latin'], display: 'swap' });next/font host de fonts lokaal, elimineert de DNS lookup naar Google, en voegt font-display: swap toe.
Resultaat: 3 externe requests geëlimineerd. LCP naar 2.6s.
Stap 3: JavaScript bundle verkleinen (-0.8s)
Met @next/bundle-analyzer ontdekte ik twee problemen:
- Chart.js werd volledig geïmporteerd (210KB) terwijl ik alleen bar charts gebruikte
- date-fns importeerde de hele library (75KB) in plaats van individuele functies
// ❌ Import alles
import { format, parseISO, differenceInDays } from 'date-fns';
// ✅ Tree-shakeable imports
import format from 'date-fns/format';
import parseISO from 'date-fns/parseISO';En voor Chart.js: registreer alleen de modules die je gebruikt (zie mijn BlogChart component).
Resultaat: Totale JS bundle van 485KB naar 220KB. LCP naar 1.8s.
Stap 4: Server-side caching (-0.6s)
De pagina deed 4 database queries bij elk request. Twee daarvan veranderden zelden:
// Layout data cached voor 1 uur
const navigation = await unstable_cache(
() => getNavigationItems(),
['nav-items'],
{ revalidate: 3600 }
)();
// User-specifieke data altijd vers
const dashboardData = await getDashboardData(userId);Resultaat: TTFB van 1.2s naar 0.4s. LCP naar 1.2s.
Stap 5: Streaming met Suspense (-0.4s)
De dashboard pagina wachtte tot alle data geladen was voordat er iets werd getoond. Met Suspense boundaries stream je de snelle content eerst:
export default function Dashboard() {
return (
<div>
{/* Snel: uit cache */}
<DashboardHeader />
{/* Langzaam: database query */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}Resultaat: LCP naar 0.8s. De gebruiker ziet direct content terwijl charts laden.
Eindresultaat
| Metric | Voor | Na | Verbetering |
|---|---|---|---|
| LCP | 4.1s | 0.8s | -80% |
| FID | 180ms | 45ms | -75% |
| CLS | 0.24 | 0.02 | -92% |
| TTFB | 1.2s | 0.4s | -67% |
| JS Bundle | 485KB | 220KB | -55% |
Les geleerd
De grootste winsten kwamen van de simpelste optimalisaties: images en fonts. Begin daar altijd. JavaScript bundle optimalisatie en streaming zijn belangrijk, maar leveren pas resultaat nadat de basis op orde is.
Voor optimalisatie
- ❌LCP: 4.1 seconden
- ❌JS Bundle: 485KB
- ❌Geen image optimalisatie
- ❌3 externe font requests
- ❌Geen caching strategie
Na optimalisatie
- ✅LCP: 0.8 seconden
- ✅JS Bundle: 220KB
- ✅WebP met responsive sizes
- ✅Lokale fonts met swap
- ✅Agressieve server-side cache
Techniek | ImpactAanbevolen | Moeite | Prioriteit | |
|---|---|---|---|---|
| next/image optimalisatie | Images | Hoog (-0.9s) | Laag (30 min) | 1 - Begin hier |
| next/font lokaal | Fonts | Gemiddeld (-0.6s) | Laag (15 min) | 2 - Quick win |
| Tree-shaking & imports | JS Bundle | Hoog (-0.8s) | Gemiddeld (2-4 uur) | 3 - Analyse nodig |
| unstable_cache | TTFB | Hoog (-0.6s) | Gemiddeld (1-2 uur) | 4 - Data-afhankelijk |
| Suspense streaming | Perceived perf | Gemiddeld (-0.4s) | Hoog (4+ uur) | 5 - Architectureel |
Prioriteer optimalisaties op basis van impact en moeite — begin altijd met images en fonts
Bronnen
De tools die ik gebruikte:
- Lighthouse voor de initiële audit
- @next/bundle-analyzer voor bundle analyse
- WebPageTest voor real-world metingen op verschillende verbindingen
- Chrome DevTools Performance tab voor waterfall analyse