Ga naar inhoud
·24 min leestijd

Core Web Vitals in 2026: Wat Echt het Verschil Maakt

Vergeet de theorie — dit is wat ik daadwerkelijk deed om LCP onder 2,5s te krijgen, CLS naar nul en INP onder 200ms op een echte Next.js-productiesite. Specifieke technieken, geen vaag advies.

Delen:X / TwitterLinkedIn

Ik heb het grootste deel van twee weken besteed aan het snel maken van deze site. Niet "ziet er snel uit in een Lighthouse-audit op mijn M3 MacBook" snel. Echt snel. Snel op een Android-telefoon van 150 euro op een wankele 4G-verbinding in een metrotunnel. Snel waar het ertoe doet.

Het resultaat: LCP onder 1,8s, CLS op 0,00, INP onder 120ms. Alle drie groen in CrUX-data, niet alleen labscores. En ik leerde iets in het proces — de meeste prestatieadviezen op het internet zijn ofwel verouderd, vaag, of beide.

"Optimaliseer je afbeeldingen" is geen advies. "Gebruik lazy loading" zonder context is gevaarlijk. "Minimaliseer JavaScript" is voor de hand liggend maar vertelt je niets over wat je moet schrappen.

Dit is wat ik daadwerkelijk deed, in de volgorde die ertoe deed.

Waarom Core Web Vitals Nog Steeds Belangrijk Zijn in 2026#

Laat me direct zijn: Google gebruikt Core Web Vitals als rankingsignaal. Niet het enige signaal, en niet eens het belangrijkste. Contentrelevantie, backlinks en domeinautoriteit domineren nog steeds. Maar in de marges — waar twee pagina's vergelijkbare content en autoriteit hebben — is prestatie een tiebreaker. En op het internet leven miljoenen pagina's in die marges.

Maar vergeet SEO even. De echte reden om om prestaties te geven zijn gebruikers. De data is de afgelopen vijf jaar niet veel veranderd:

  • 53% van mobiele bezoeken wordt afgebroken als een pagina langer dan 3 seconden laadt (Google/SOASTA-onderzoek, geldt nog steeds)
  • Elke 100ms latentie kost ruwweg 1% aan conversies (Amazon's oorspronkelijke bevinding, herhaaldelijk gevalideerd)
  • Gebruikers die layout shifts ervaren zijn significant minder geneigd om een aankoop te voltooien of een formulier in te vullen

Core Web Vitals in 2026 bestaan uit drie metrics:

MetricWat het MeetGoedVerbetering NodigSlecht
LCPLaadprestatie≤ 2,5s2,5s – 4,0s> 4,0s
CLSVisuele stabiliteit≤ 0,10,1 – 0,25> 0,25
INPResponsiviteit≤ 200ms200ms – 500ms> 500ms

Deze drempelwaarden zijn niet veranderd sinds INP FID verving in maart 2024. Maar de technieken om ze te halen zijn geëvolueerd, vooral in het React/Next.js-ecosysteem.

LCP: Degene Die het Meest Uitmaakt#

Largest Contentful Paint meet wanneer het grootste zichtbare element in de viewport klaar is met renderen. Voor de meeste pagina's is dit een hero-afbeelding, een heading of een groot blok tekst.

Stap 1: Vind Je Werkelijke LCP-Element#

Voordat je iets optimaliseert, moet je weten wat je LCP-element is. Mensen nemen aan dat het hun hero-afbeelding is. Soms is het een webfont dat de <h1> rendert. Soms is het een achtergrondafbeelding via CSS. Soms is het een <video> posterframe.

Open Chrome DevTools, ga naar het Performance-panel, neem een pageload op en zoek naar de "LCP" marker. Die vertelt je precies welk element LCP triggerde.

Je kunt ook de web-vitals library gebruiken om het programmatisch te loggen:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

Op deze site bleek het LCP-element de hero-afbeelding op de homepage te zijn en de eerste paragraaf tekst op blogposts. Twee verschillende elementen, twee verschillende optimalisatiestrategieën.

Stap 2: Preload de LCP-Resource#

Als je LCP-element een afbeelding is, is het meest impactvolle wat je kunt doen het preloaden ervan. Standaard ontdekt de browser afbeeldingen wanneer het de HTML parst, wat betekent dat het afbeeldingsverzoek pas start nadat de HTML is gedownload, geparsed en de <img> tag is bereikt. Preloading verplaatst die ontdekking naar het allereerste begin.

In Next.js kun je een preload-link toevoegen in je layout of pagina:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Let op fetchPriority="high". Dit is de nieuwere Fetch Priority API, en het is een game changer. Zonder dit gebruikt de browser zijn eigen heuristieken om resources te prioriteren — en die heuristieken hebben het vaak fout, vooral wanneer je meerdere afbeeldingen boven de vouw hebt.

Op deze site zorgde het toevoegen van fetchPriority="high" aan de LCP-afbeelding voor een daling van LCP met ~400ms. Dat is de grootste enkele winst die ik ooit uit een wijziging van één regel heb gehaald.

Stap 3: Elimineer Render-Blokkerende Resources#

CSS blokkeert rendering. Allemaal. Als je een stylesheet van 200KB hebt geladen via <link rel="stylesheet">, zal de browser niets painten totdat het volledig gedownload en geparsed is.

De oplossing is drieledig:

  1. Inline critical CSS — Extraheer de CSS die nodig is voor above-the-fold content en inline het in een <style> tag in de <head>. Next.js doet dit automatisch wanneer je CSS Modules of Tailwind met goede purging gebruikt.

  2. Defer non-critical CSS — Als je stylesheets hebt voor below-the-fold content (een footer-animatiebibliotheek, een chartcomponent), laad ze asynchroon:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Verwijder ongebruikte CSS — Tailwind CSS v4 doet dit automatisch met zijn JIT-engine. Maar als je third-party CSS-bibliotheken importeert, audit ze. Ik vond een componentbibliotheek die 180KB CSS importeerde voor een enkel tooltip-component. Vervangen door een custom component van 20 regels en 170KB bespaard.

Stap 4: Server Response Time (TTFB)#

LCP kan niet snel zijn als TTFB traag is. Als je server 800ms nodig heeft om te antwoorden, is je LCP minimaal 800ms + al het andere.

Op deze site (Node.js + PM2 + Nginx op een VPS) mat ik TTFB op ongeveer 180ms bij een cold hit. Dit is wat ik deed om dat zo te houden:

  • ISR (Incremental Static Regeneration) voor blogposts — pagina's worden pre-gerenderd bij build time en periodiek gehervalideerd. Eerste bezoek serveert een statisch bestand direct vanuit Nginx's reverse proxy cache.
  • Edge caching headersCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 op statische pagina's.
  • Gzip/Brotli compressie in Nginx — vermindert transfer-grootte met 60-80%.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (als ngx_brotli module is geïnstalleerd)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Mijn voor/na op LCP:

  • Voor optimalisatie: 3,8s (75e percentiel, CrUX)
  • Na preload + fetchPriority + compressie: 1,8s
  • Totale verbetering: 53% reductie

CLS: Dood door Duizend Verschuivingen#

Cumulative Layout Shift meet hoeveel zichtbare content verschuift tijdens het laden van de pagina. Een CLS van 0 betekent dat niets verschoof. Een CLS boven 0,1 betekent dat iets je gebruikers visueel irriteert.

CLS is de metric die de meeste developers onderschatten. Je merkt het niet op je snelle ontwikkelmachine met alles gecacht. Je gebruikers merken het op hun telefoons, op trage verbindingen, waar fonts laat laden en afbeeldingen één voor één verschijnen.

De Gebruikelijke Boosdoeners#

1. Afbeeldingen zonder expliciete afmetingen

Dit is de meest voorkomende CLS-oorzaak. Wanneer een afbeelding laadt, duwt het content eronder naar beneden. De oplossing is gênant simpel: specificeer altijd width en height op <img> tags.

tsx
// SLECHT — veroorzaakt layout shift
<img src="/photo.jpg" alt="Teamfoto" />
 
// GOED — browser reserveert ruimte voordat afbeelding laadt
<img src="/photo.jpg" alt="Teamfoto" width={800} height={450} />

Als je Next.js <Image> gebruikt, handelt het dit automatisch af zolang je afmetingen opgeeft of fill gebruikt met een container met afmetingen.

Maar hier is de valkuil: als je fill mode gebruikt, moet de oudercontainer expliciete afmetingen hebben, anders veroorzaakt de afbeelding een CLS:

tsx
// SLECHT — ouder heeft geen afmetingen
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GOED — ouder heeft expliciete beeldverhouding
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Webfonts die FOUT/FOIT veroorzaken

Wanneer een custom font laadt, wordt tekst die in het fallback-font is gerenderd opnieuw gerenderd in het custom font. Als de twee fonts verschillende metrics hebben (dat hebben ze bijna altijd), verschuift alles.

De moderne oplossing is font-display: swap gecombineerd met size-adjusted fallback fonts:

tsx
// next/font gebruiken — de beste aanpak voor Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font genereert automatisch size-adjusted fallback fonts
  // Dit elimineert CLS door font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="nl" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font is oprecht een van de beste dingen in Next.js. Het downloadt fonts bij build time, self-host ze (geen Google Fonts request at runtime), en genereert size-adjusted fallback fonts zodat de swap van fallback naar custom font nul layout shift veroorzaakt. Ik mat CLS van fonts op 0,00 na de overstap naar next/font. Daarvoor, met een standaard Google Fonts <link>, was het 0,04-0,08.

3. Dynamische content-injectie

Advertenties, cookiebanners, meldingsbalken — alles wat na de initiële render in de DOM wordt geïnjecteerd veroorzaakt CLS als het content naar beneden duwt.

De oplossing: reserveer ruimte voor dynamische content voordat het laadt.

tsx
// Cookiebanner — reserveer ruimte onderaan
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positionering veroorzaakt geen CLS omdat het
    // de documentstroom niet beïnvloedt
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We gebruiken cookies. Je kent het verhaal.</p>
      <button onClick={() => setAccepted(true)}>Accepteren</button>
    </div>
  );
}

position: fixed of position: absolute gebruiken voor dynamische elementen is een CLS-vrije aanpak omdat deze elementen de normale documentstroom niet beïnvloeden.

4. De aspect-ratio CSS-truc

Voor responsieve containers waarvan je de beeldverhouding kent maar niet de exacte afmetingen, gebruik de CSS aspect-ratio property:

tsx
// Video-embed zonder CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Ingesloten video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

De aspect-video utility (wat aspect-ratio: 16/9 is) reserveert precies de juiste hoeveelheid ruimte. Geen verschuiving wanneer het iframe laadt.

5. Skeleton screens

Voor content die asynchroon laadt (API-data, dynamische componenten), toon een skeleton die overeenkomt met de verwachte afmetingen:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

De sleutel is dat PostCardSkeleton en PostCard dezelfde afmetingen moeten hebben. Als de skeleton 200px hoog is en de werkelijke kaart 280px hoog, krijg je alsnog een verschuiving.

Mijn CLS-resultaten:

  • Voor: 0,12 (de font swap alleen was al 0,06)
  • Na: 0,00 — letterlijk nul, over duizenden pageloads in CrUX-data

INP: De Nieuweling Die Bijt#

Interaction to Next Paint verving First Input Delay in maart 2024, en het is een fundamenteel moeilijkere metric om te optimaliseren. FID mat alleen de vertraging voordat de eerste interactie werd verwerkt. INP meet elke interactie gedurende de hele levenscyclus van de pagina en rapporteert de slechtste (op het 75e percentiel).

Dit betekent dat een pagina geweldige FID kan hebben maar verschrikkelijke INP als, zeg, het klikken op een dropdownmenu 30 seconden na laden een reflow van 500ms triggert.

Wat Hoge INP Veroorzaakt#

  1. Lange taken op de main thread — Elke JavaScript-uitvoering die langer dan 50ms duurt blokkeert de main thread. Gebruikersinteracties die plaatsvinden tijdens een lange taak moeten wachten.
  2. Dure re-renders in React — Een state-update die 200 componenten laat re-renderen kost tijd. De gebruiker klikt op iets, React reconcilieert, en de paint vindt pas na 300ms plaats.
  3. Layout thrashing — Layoutproperties lezen (zoals offsetHeight) en dan schrijven (zoals style.height veranderen) in een loop dwingt de browser om layout synchroon te herberekenen.
  4. Grote DOM — Meer DOM-nodes betekent tragere stijlherberekening en layout. Een DOM met 5.000 nodes is merkbaar trager dan een met 500.

Lange Taken Opbreken met scheduler.yield()#

De meest impactvolle techniek voor INP is het opbreken van lange taken zodat de browser gebruikersinteracties kan verwerken tussen chunks:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Elke 10 items, yield aan de browser
    // Dit laat lopende gebruikersinteracties verwerkt worden
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() is beschikbaar in Chrome 129+ (september 2024) en is de aanbevolen manier om te yielden aan de main thread. Voor browsers die het niet ondersteunen, kun je terugvallen op een setTimeout(0) wrapper:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition voor Niet-Urgente Updates#

React 18+ geeft ons useTransition, dat React vertelt dat bepaalde state-updates niet urgent zijn en onderbroken kunnen worden door belangrijker werk (zoals reageren op gebruikersinvoer):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // Deze update is urgent — de input moet de toetsaanslag onmiddellijk reflecteren
    setQuery(value);
 
    // Deze update is NIET urgent — 10.000 items filteren kan wachten
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Zoeken..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filteren...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Zonder startTransition zou typen in het zoekveld traag aanvoelen omdat React zou proberen 10.000 items synchroon te filteren voordat de DOM wordt bijgewerkt. Met startTransition wordt de input onmiddellijk bijgewerkt en vindt het filteren op de achtergrond plaats.

Ik mat INP op een toolpagina met een complexe input handler. Voor useTransition: 380ms INP. Na: 90ms INP. Dat is een verbetering van 76% door een React API-wijziging.

Debouncing van Input Handlers#

Voor handlers die dure operaties triggeren (API-calls, zware berekeningen), debounce ze:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Gebruik
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Zoeken..."
    />
  );
}

300ms is mijn standaard debounce-waarde. Het is kort genoeg dat gebruikers de vertraging niet merken, lang genoeg om niet bij elke toetsaanslag af te vuren.

Web Workers voor Zware Berekeningen#

Als je echt zware berekeningen hebt (grote JSON parsen, afbeeldingsmanipulatie, complexe berekeningen), verplaats ze dan volledig van de main thread:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // Dit kan 500ms duren voor grote datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers draaien op een aparte thread, dus zelfs een berekening van 2 seconden beïnvloedt INP helemaal niet. De main thread blijft vrij om gebruikersinteracties te verwerken.

Mijn INP-resultaten:

  • Voor: 340ms (slechtste interactie was een regex tester tool met complexe invoerverwerking)
  • Na useTransition + debouncing: 110ms
  • Verbetering: 68% reductie

De Next.js-Specifieke Winsten#

Als je op Next.js zit (13+ met App Router), heb je toegang tot een aantal krachtige prestatieprimitieven die de meeste developers niet volledig benutten.

next/image — Maar Correct Geconfigureerd#

next/image is geweldig, maar de standaardconfiguratie laat prestatie liggen:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 jaar
  },
};
 
export default nextConfig;

Belangrijke instellingen:

  • formats: ["image/avif", "image/webp"] — AVIF is 20-50% kleiner dan WebP. De volgorde doet ertoe: Next.js probeert eerst AVIF, valt terug op WebP, dan op het originele formaat.
  • minimumCacheTTL — Standaard is 60 seconden. Voor een blog veranderen afbeeldingen niet. Cache ze voor een jaar.
  • deviceSizes en imageSizes — De standaarden bevatten 3840px. Tenzij je 4K-afbeeldingen serveert, trim deze lijst. Elke grootte genereert een apart gecacht bestand, en ongebruikte groottes verspillen schijfruimte en buildtijd.

En gebruik altijd de sizes prop om de browser te vertellen op welke grootte de afbeelding wordt gerenderd:

tsx
// Volledig brede hero-afbeelding
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP-afbeelding — niet lazy loaden!
/>
 
// Kaartafbeelding in een responsive grid
<Image
  src="/card.jpg"
  alt="Kaart"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Zonder sizes downloadt de browser mogelijk een afbeelding van 1200px voor een slot van 300px. Dat zijn verspilde bytes en verspilde tijd.

De priority prop op de LCP-afbeelding is cruciaal. Het schakelt lazy loading uit en voegt automatisch fetchPriority="high" toe. Als je LCP-element een next/image is, voeg gewoon priority toe en je bent het grootste deel van de weg.

next/font — Fonts Zonder Layout Shift#

Ik behandelde dit in de CLS-sectie, maar het verdient nadruk. next/font is de enige fontlaadoplossing die ik heb gezien die consistent nul CLS bereikt:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="nl"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Twee fonts, nul CLS, nul externe requests at runtime. De fonts worden bij build time gedownload en geserveerd vanaf je eigen domein.

Streaming met Suspense#

Dit is waar Next.js echt interessant wordt voor prestatie. Met de App Router kun je delen van de pagina naar de browser streamen zodra ze beschikbaar zijn:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* Dit laadt snel — stream het meteen */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* Dit vereist een database-query — stream het wanneer klaar */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar kan onafhankelijk laden */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

De browser ontvangt de shell (heading, navigatie, layout) onmiddellijk. De postlijst en sidebar streamen in wanneer hun data beschikbaar wordt. De gebruiker ziet een snelle initiële lading en content vult progressief aan.

Dit is bijzonder krachtig voor LCP. Als je LCP-element de heading is (niet de postlijst), rendert het onmiddellijk ongeacht hoe lang de database-query duurt.

Route Segment Configuratie#

Next.js laat je caching en hervalidatie configureren op route segment-niveau:

tsx
// app/blog/page.tsx
// Hervalideer deze pagina elk uur
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// Deze toolpagina's zijn volledig statisch — genereer bij build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API-route — nooit cachen
export const dynamic = "force-dynamic";

Op deze site gebruiken blogposts revalidate = 3600 (1 uur). Toolpagina's gebruiken force-static omdat hun content niet verandert tussen deploys. De zoek-API gebruikt force-dynamic omdat elk verzoek uniek is.

Het resultaat: de meeste pagina's worden geserveerd vanuit de statische cache, TTFB is onder 50ms voor gecachte pagina's, en de server heeft nauwelijks moeite.

Meettools: Vertrouw de Data, Niet Je Ogen#

Je perceptie van prestatie is onbetrouwbaar. Je ontwikkelmachine heeft 32GB RAM, een NVMe SSD en een gigabit-verbinding. Je gebruikers niet.

De Meetstack Die Ik Gebruik#

1. Chrome DevTools Performance-panel

De meest gedetailleerde tool die beschikbaar is. Neem een pageload op, bekijk de flamechart, identificeer lange taken, vind render-blokkerende resources. Dit is waar ik het grootste deel van mijn debugtijd doorbreng.

Belangrijke dingen om op te letten:

  • Rode hoeken op taken = lange taken (>50ms)
  • Layout/Paint events getriggerd door JavaScript
  • Grote "Evaluate Script" blokken (te veel JavaScript)
  • Network waterfall die laat-ontdekte resources toont

2. Lighthouse

Goed voor een snelle check, maar optimaliseer niet voor Lighthouse-scores. Lighthouse draait in een gesimuleerde throttled omgeving die niet perfect overeenkomt met real-world omstandigheden. Ik heb pagina's 98 zien scoren in Lighthouse en 4s LCP hebben in het veld.

Gebruik Lighthouse voor richtinggevende begeleiding, niet als scorebord.

3. PageSpeed Insights

De belangrijkste tool voor productiesites omdat het echte CrUX-data toont — daadwerkelijke metingen van echte Chrome-gebruikers over de afgelopen 28 dagen. Labdata vertelt je wat zou kunnen gebeuren. CrUX-data vertelt je wat daadwerkelijk gebeurt.

4. De web-vitals Library

Voeg dit toe aan je productiesite om real user metrics te verzamelen:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Stuur naar je analytics-endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Gebruik sendBeacon zodat het het verlaten van de pagina niet blokkeert
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Dit geeft je je eigen CrUX-achtige data, maar met meer detail. Je kunt segmenteren op pagina, apparaattype, verbindingssnelheid, geografische regio — wat je maar nodig hebt.

5. Chrome User Experience Report (CrUX)

De CrUX BigQuery-dataset is gratis en bevat 28-daags rollend data voor miljoenen origins. Als je site genoeg verkeer krijgt, kun je je eigen data bevragen:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

De Waterval-Dodenlijst#

Third-party scripts zijn de nummer één prestatiemoordenaar op de meeste websites. Dit is wat ik vond en wat ik eraan deed.

Google Tag Manager (GTM)#

GTM zelf is ~80KB. Maar GTM laadt andere scripts — analytics, marketingpixels, A/B-testtools. Ik heb GTM-configuraties gezien die 15 extra scripts laden van in totaal 2MB.

Mijn aanpak: Gebruik geen GTM in productie. Laad analyticsscripts direct, defer alles, en gebruik loading="lazy" voor scripts die kunnen wachten:

tsx
// In plaats van dat GTM alles laadt
// Laad alleen wat je nodig hebt, wanneer je het nodig hebt
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Als je absoluut GTM moet gebruiken, laad het dan nadat de pagina interactief is:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wacht tot na het laden van de pagina om GTM te injecteren
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 seconden vertraging
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Ja, je verliest data van gebruikers die binnen de eerste 3 seconden bouncen. Naar mijn ervaring is dat een afweging die het waard is. Die gebruikers converteerden toch niet.

Chat Widgets#

Live chat widgets (Intercom, Drift, Crisp) zijn een van de ergste overtreders. Intercom alleen al laadt meer dan 400KB aan JavaScript. Op een pagina waar 2% van de gebruikers daadwerkelijk op de chatknop klikt, is dat 400KB JavaScript voor 98% van de gebruikers.

Mijn oplossing: Laad de widget bij interactie.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Laad het chat widget script alleen wanneer de gebruiker klikt
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialiseer de widget nadat het script is geladen
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Chat openen"
    >
      {loaded ? "Laden..." : "Chat met ons"}
    </button>
  );
}

Ongebruikte JavaScript#

Open Coverage in Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Het toont je precies hoeveel van elk script daadwerkelijk wordt gebruikt op de huidige pagina.

Op een typische Next.js-site vind ik meestal:

  • Componentbibliotheken volledig geladen — Je importeert Button uit een UI-bibliotheek, maar de hele bibliotheek wordt gebundeld. Oplossing: gebruik tree-shakeable bibliotheken of importeer van subpaden (import Button from "lib/Button" in plaats van import { Button } from "lib").
  • Polyfills voor moderne browsers — Controleer of je polyfills voor Promise, fetch of Array.prototype.includes meestuurt. In 2026 heb je die niet nodig.
  • Dode feature flags — Codepaden achter feature flags die al zes maanden "aan" staan. Verwijder de flag en de dode branch.

Ik gebruik de Next.js bundle analyzer om te grote chunks te vinden:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // je config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Dit opent een visuele treemap van je bundels. Ik vond een datumformatteringsbibliotheek van 120KB die ik verving door native Intl.DateTimeFormat. Ik vond een markdown-parser van 90KB geïmporteerd op een pagina die geen markdown gebruikte. Kleine winsten die optellen.

Render-Blokkerende CSS#

Ik noemde dit in de LCP-sectie, maar het is het herhalen waard omdat het zo vaak voorkomt. Elke <link rel="stylesheet"> in de <head> blokkeert rendering. Als je vijf stylesheets hebt, wacht de browser op alle vijf voordat het iets paint.

Next.js met Tailwind handelt dit goed af — CSS is inline en minimaal. Maar als je third-party CSS importeert, audit het:

tsx
// SLECHT — laadt hele bibliotheek-CSS op elke pagina
import "some-library/dist/styles.css";
 
// BETER — dynamische import zodat het alleen laadt op pagina's die het nodig hebben
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS wordt geïmporteerd binnen het dynamische component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Een Echt Optimalisatieverhaal#

Laat me je door de daadwerkelijke optimalisatie van de toolpagina van deze site leiden. Het is een pagina met meer dan 15 interactieve tools, elk met een eigen component, en sommige daarvan (zoals de regex tester en JSON formatter) zijn JavaScript-intensief.

Het Startpunt#

Initiële metingen (CrUX-data, mobiel, 75e percentiel):

  • LCP: 3,8s — Slecht
  • CLS: 0,12 — Verbetering Nodig
  • INP: 340ms — Slecht

Lighthouse-score: 62.

Het Onderzoek#

LCP-analyse: Het LCP-element was de paginakop (<h1>), die direct zou moeten renderen. Maar het werd vertraagd door:

  1. Een CSS-bestand van 200KB van een componentbibliotheek (render-blokkerend)
  2. Een custom font geladen via Google Fonts CDN (FOIT van 800ms op trage verbindingen)
  3. TTFB van 420ms omdat de pagina bij elk verzoek server-gerenderd werd zonder caching

CLS-analyse: Drie bronnen:

  1. Font swap van Google Fonts fallback naar custom font: 0,06
  2. Toolkaarten die laden zonder hoogtereservering: 0,04
  3. Een cookiebanner geïnjecteerd bovenaan de pagina, die alles naar beneden duwde: 0,02

INP-analyse: De regex tester tool was de ergste boosdoener. Elke toetsaanslag in de regex-invoer triggerde:

  1. Een volledige re-render van het hele toolcomponent
  2. Regex-evaluatie tegen de teststring
  3. Syntaxhighlighting van het regex-patroon

Totale tijd per toetsaanslag: 280-400ms.

De Oplossingen#

Week 1: LCP en CLS

  1. Google Fonts CDN vervangen door next/font. Font is nu self-hosted, geladen bij build time, met size-adjusted fallback. CLS van fonts: 0,06 → 0,00

  2. De CSS van de componentbibliotheek verwijderd. De 3 componenten die ik ervan gebruikte herschreven met Tailwind. Totaal verwijderde CSS: 180KB. Render-blokkerende CSS: geëlimineerd

  3. revalidate = 3600 toegevoegd aan de toolpagina en tool-detailpagina's. Eerste hit is server-gerenderd, volgende hits serveren uit cache. TTFB: 420ms → 45ms (gecacht)

  4. Expliciete afmetingen toegevoegd aan alle toolkaartcomponenten en aspect-ratio gebruikt voor responsieve layouts. CLS van kaarten: 0,04 → 0,00

  5. Cookiebanner verplaatst naar position: fixed onderaan het scherm. CLS van banner: 0,02 → 0,00

Week 2: INP

  1. De resultaatberekening van de regex tester gewrapt in startTransition:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update de input
 
    startTransition(() => {
      // Niet-urgent: bereken matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

INP op regex tester: 380ms → 85ms

  1. Debouncing toegevoegd aan de invoerhandler van de JSON formatter (300ms vertraging). INP op JSON formatter: 260ms → 60ms

  2. De berekening van de hash generator verplaatst naar een Web Worker. SHA-256 hashing van grote invoer gebeurt nu volledig buiten de main thread. INP op hash generator: 200ms → 40ms

De Resultaten#

Na twee weken optimalisatie (CrUX-data, mobiel, 75e percentiel):

  • LCP: 3,8s → 1,8s (53% verbetering)
  • CLS: 0,12 → 0,00 (100% verbetering)
  • INP: 340ms → 110ms (68% verbetering)

Lighthouse-score: 62 → 97.

Alle drie de metrics stevig in het "Goed" bereik. De pagina voelt instant aan op mobiel. En organisch zoekverkeer steeg 12% in de maand na de verbeteringen (hoewel ik geen causaliteit kan bewijzen — andere factoren speelden mee).

De Checklist#

Als je niets anders uit dit artikel meeneemt, hier is de checklist die ik op elk project doorloop:

LCP#

  • Identificeer het LCP-element met DevTools
  • Voeg priority (of fetchPriority="high") toe aan de LCP-afbeelding
  • Preload LCP-resources in <head>
  • Elimineer render-blokkerende CSS
  • Self-host fonts met next/font
  • Schakel Brotli/Gzip compressie in
  • Gebruik statische generatie of ISR waar mogelijk
  • Stel agressieve cache headers in voor statische assets

CLS#

  • Alle afbeeldingen hebben expliciete width en height
  • Gebruik next/font met size-adjusted fallbacks
  • Dynamische content gebruikt position: fixed/absolute of gereserveerde ruimte
  • Skeleton screens komen overeen met werkelijke componentafmetingen
  • Geen content-injectie bovenaan de pagina na laden

INP#

  • Geen lange taken (>50ms) tijdens interactie-handlers
  • Niet-urgente state-updates gewrapt in startTransition
  • Input handlers gedebounced (300ms)
  • Zware berekeningen verplaatst naar Web Workers
  • DOM-grootte onder 1.500 nodes waar mogelijk

Algemeen#

  • Third-party scripts geladen na pagina interactief
  • Bundelgrootte geanalyseerd en tree-shaken
  • Ongebruikte CSS verwijderd
  • Afbeeldingen geserveerd in AVIF/WebP formaat
  • Real user monitoring in productie (web-vitals library)

Slotgedachten#

Prestatieoptimalisatie is geen eenmalige taak. Het is een discipline. Elke nieuwe feature, elke nieuwe dependency, elk nieuw third-party script is een potentiële regressie. De sites die snel blijven zijn degene waar iemand continu de metrics in de gaten houdt, niet degene waar iemand een eenmalige optimalisatiesprint deed.

Stel real user monitoring in. Stel alerts in wanneer metrics regresseren. Maak prestatie onderdeel van je code review-proces. Wanneer iemand een bibliotheek van 200KB toevoegt, vraag of er een alternatief van 5KB is. Wanneer iemand een synchrone berekening in een event handler toevoegt, vraag of het uitgesteld of naar een worker verplaatst kan worden.

De technieken in dit artikel zijn niet theoretisch. Het is wat ik daadwerkelijk deed, op deze site, met echte cijfers om het te onderbouwen. Je resultaten zullen variëren — elke site is anders, elke doelgroep is anders, elke infrastructuur is anders. Maar de principes zijn universeel: laad minder, laad slimmer, blokkeer de main thread niet.

Je gebruikers zullen je geen bedankbriefje sturen voor een snelle site. Maar ze blijven. Ze komen terug. En Google merkt het.

Gerelateerde artikelen