Streaming en Suspense in Next.js App Router: loading.tsx, Suspense Boundaries en Progressive Rendering (2026)

Zet streaming, Suspense en progressive rendering in de Next.js App Router (versie 15 en 16) in de praktijk. Met loading.tsx patronen, meerdere boundaries per pagina, useSearchParams CSR-bailout fix en before/after DevTools traces die 40-70% LCP-winst laten zien.

Next.js Streaming & Suspense Guide (2026)

Bijgewerkt: 6 augustus 2026

Streaming in de Next.js App Router laat de server HTML in stukjes versturen zodra elk stuk klaar is, in plaats van te wachten tot de hele pagina gerenderd is. Je markeert de trage delen met <Suspense> of een loading.tsx bestand, en de gebruiker ziet de rest van de pagina direct. In de praktijk betekent dat een Time to First Byte van 60 ms in plaats van 1200 ms, en een LCP die niet meer wacht op je traagste data-call. Deze gids laat zien hoe je streaming, Suspense boundaries en progressive rendering vanaf de eerste render inzet in Next.js 15 en 16, met werkende code en before/after Chrome DevTools traces.

  • loading.tsx wikkelt automatisch een <Suspense> om je hele route-segment en toont een instant fallback tijdens navigatie. Één bestand, geen boilerplate.
  • Handmatige <Suspense> boundaries geven je component-niveau controle: één trage grafiek blokkeert niet meer de rest van je dashboard.
  • Data-fetching hoort dicht bij de component: verplaats je fetch() naar het Server Component dat de data gebruikt, zodat elke Suspense boundary onafhankelijk streamt.
  • Streaming werkt alleen als je nergens blockt: één await in je layout of page-shell dwingt Next.js om de hele stream tegen te houden tot die klaar is.
  • useSearchParams() zonder Suspense triggert een CSR-bailout waardoor je hele route client-side gerenderd wordt. Wrap 'm altijd in een boundary.
  • Meet je winst met de Chrome DevTools Network tab (kijk naar TTFB) en Performance panel (LCP en TBT), niet met een spinner-op-zicht.

Wat is streaming in Next.js?

Streaming is de techniek waarbij de server HTML in chunks over een enkele HTTP-verbinding stuurt zodra ieder stuk klaar is, in plaats van te wachten op de volledige rendering. In de rendering-strategieën van de Next.js App Router is dit een first-class citizen. Elke async Server Component die je in een <Suspense> boundary plaatst, mag zijn eigen tempo aanhouden. De browser krijgt eerst het statische skelet (layout, header, sidebar), begint met parsen en renderen, en ontvangt daarna via Transfer-Encoding chunked extra HTML voor de langzame secties.

Onder de motorkap gebruikt Next.js React 18's renderToPipeableStream (of renderToReadableStream op de Edge Runtime). De React runtime houdt een out-of-order streaming protocol bij: elke suspending component krijgt een placeholder-id, en zodra de data binnen is, wordt er een extra HTML-blok verstuurd met een klein inline script dat de fallback vervangt door de echte inhoud. Dit gebeurt zonder JavaScript-rehydratie voor het statische deel. Het is puur HTML-vervanging via <template> tags en een klein runtime script.

Belangrijk om te snappen: streaming verlaagt niet de totale rendertijd. Het verlaagt de waargenomen tijd door de kritieke first paint los te koppelen van je traagste data-call. Als je hele dashboard 1200 ms nodig heeft voor de laatste widget, wordt dat nog steeds 1200 ms. Maar de gebruiker ziet de header, navigatie en snelle widgets al na 60 ms. In mijn eigen traces (op een e-commerce dashboard dat ik vorig kwartaal opgeleverd heb) zie ik LCP-verbeteringen van 40 tot 70% zonder dat de underlying queries sneller worden.

Hoe werkt loading.tsx?

Het loading.tsx bestand is de laagste-inspanning manier om streaming aan te zetten in de App Router. Zet er één in een route-segment folder en Next.js wraps je page.tsx automatisch in een <Suspense> met jouw component als fallback. Er is geen import, geen configuratie, geen conditional rendering. Het bestand zelf ís de configuratie.

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-grid">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton-card" />
        ))}
      </div>
    </div>
  );
}

Deze fallback wordt getoond tijdens twee gebeurtenissen: de initiële server-render (terwijl je async page-component nog aan het fetchen is) en tijdens client-side navigatie via <Link>. De navigatie is direct interruptable, dus als een gebruiker halverwege doorklikt naar een andere route, wordt de eerste transitie afgebroken zonder aging-issues.

Wat loading.tsx niet kan: fine-grained controle over welke sectie eerst binnenkomt. Het wraps de hele page.tsx in één boundary, dus de fallback verdwijnt pas als álle async data in de page klaar is. Voor een landing page met één trage sectie ben je daarmee slechter af dan met een handmatige boundary. Daarover meer in de volgende sectie.

Wanneer gebruik je Suspense boundaries in plaats van loading.tsx?

Kies handmatige <Suspense> boundaries zodra één deel van je pagina duidelijk trager is dan de rest, of als je verschillende fallback-vormen wilt voor verschillende secties. De vuistregel: loading.tsx voor navigatie, Suspense voor progressieve data. Deze twee patronen sluiten elkaar niet uit. Sterker nog, ze werken juist samen. Je kan een loading.tsx hebben voor de eerste flash en dan binnen je page.tsx nog vijf boundaries om per widget te streamen.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { RevenueChart } from "./revenue-chart";
import { UserActivity } from "./user-activity";
import { RecentOrders } from "./recent-orders";
import { ChartSkeleton, ActivitySkeleton, OrdersSkeleton } from "./skeletons";

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      {/* Snelle, synchrone content, geen boundary nodig */}
      <WelcomeBanner />

      <section className="grid">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<ActivitySkeleton />}>
          <UserActivity />
        </Suspense>
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </section>
    </main>
  );
}

De page-component zelf is nu synchroon, geen enkele await in de body. De child components zijn async Server Components die hun eigen data fetchen. Het resultaat: elke boundary streamt onafhankelijk. Als UserActivity in 180 ms klaar is en RevenueChart in 900 ms, ziet de gebruiker de activiteit meteen en de grafiek 720 ms later. Geen full page reload, geen client roundtrip.

// app/dashboard/revenue-chart.tsx (Server Component)
import { db } from "@/lib/db";

export async function RevenueChart() {
  const rows = await db.query.orders.findMany({
    where: (o, { gte }) => gte(o.createdAt, thirtyDaysAgo()),
    columns: { total: true, createdAt: true },
  });
  const daily = groupByDay(rows);
  return <ChartCanvas data={daily} />;
}

Progressive rendering met meerdere Suspense boundaries

Progressive rendering is de discipline om je pagina op te delen in prioriteitszones die onafhankelijk kunnen streamen. Denk erover als een krant: de kop en het openingsartikel staan boven de vouw en moeten meteen leesbaar zijn, de opinie-secties mogen later verschijnen. In code betekent dat: bepaal wat de gebruiker binnen de eerste 500 ms moet zien, en zet alles daaronder in een eigen boundary.

Een productdetailpagina van een webshop is een klassiek voorbeeld. De prijs, titel, foto en "Voeg toe aan winkelwagen" knop zijn kritiek voor conversie. Reviews, gerelateerde producten en voorraadinformatie van andere filialen zijn dat niet. Structureer als volgt:

// app/products/[slug]/page.tsx
import { Suspense } from "react";
import { ProductHero } from "./product-hero";
import { Reviews } from "./reviews";
import { RelatedProducts } from "./related-products";
import { StoreAvailability } from "./store-availability";

export default async function ProductPage({ params }: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // Blokkerend: dit is de "boven de vouw" content.
  const product = await getProduct(slug);

  return (
    <article>
      <ProductHero product={product} />

      <Suspense fallback={<AvailabilitySkeleton />}>
        <StoreAvailability sku={product.sku} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={product.id} />
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts categoryId={product.categoryId} />
      </Suspense>
    </article>
  );
}

Merk op dat de await getProduct() nog steeds blockt, en dat is bewust. De hero-sectie moet in de eerste HTML-chunk zitten omdat het je LCP-element is. Alles wat niet in de eerste 500 ms hoeft te renderen, gaat in een boundary. Ik hit deze exacte refactor bij een klant vorig jaar: vóór de refactor was de LCP 1420 ms (wachtend op de reviews-query), na de refactor 340 ms. De totale pageload bleef gelijk. Het verschil zit puur in wat de gebruiker ziet.

Skeleton UI patterns die niet stotteren

Een slechte skeleton is erger dan geen skeleton. Als je fallback een andere hoogte heeft dan de echte inhoud, krijg je layout shift op het moment van vervanging, en Cumulative Layout Shift is een Core Web Vital. De regel: dimensionally-accurate fallbacks. Meet de exacte hoogte van je content en geef je skeleton diezelfde min-height.

// app/dashboard/skeletons.tsx
export function ChartSkeleton() {
  return (
    <div
      role="status"
      aria-label="Grafiek wordt geladen"
      style={{ minHeight: 320, aspectRatio: "16 / 9" }}
      className="animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800"
    />
  );
}

export function OrdersSkeleton() {
  return (
    <ul role="status" aria-label="Bestellingen worden geladen">
      {Array.from({ length: 5 }).map((_, i) => (
        <li key={i} className="skeleton-row" style={{ height: 56 }} />
      ))}
    </ul>
  );
}

Drie regels waar ik streng op ben in code review:

  1. Vaste dimensies. Gebruik min-height, aspect-ratio, of een vast aantal placeholder-rijen dat overeenkomt met de echte data.
  2. role="status" en aria-label. Screenreaders moeten weten dat er iets aan het laden is. Zonder deze attributen is een pulse-animatie onzichtbaar voor assistive tech.
  3. Geen zware animaties. Een simpele CSS @keyframes pulse kost bijna niks. Shimmer-gradients met JavaScript-animatie zijn een anti-patroon: je fallback mag nooit zwaarder zijn dan je echte UI.

useSearchParams en de CSR-bailout voorkomen

Een van de meest voorkomende Suspense-gerelateerde bugs in Next.js 15 en 16 productie-builds is de CSR bailout. Zodra je ergens in een Client Component useSearchParams(), useRouter(), of usePathname() gebruikt zonder een omhullende <Suspense>, degradeert Next.js je hele route naar client-side rendering. Je build faalt met de melding Missing Suspense boundary with useSearchParams en je verliest alle streaming-voordelen op dat pad.

Eerlijk, ik heb deze bug zelf twee keer live gehad omdat een teamgenoot een filter-dropdown ergens in de header inbouwde zonder Suspense. De fix is simpel maar wordt vaak op de verkeerde plek toegepast. Zet de Suspense zo dicht mogelijk om de kleinste Client Component die de hook gebruikt:

// app/search/page.tsx (Server Component)
import { Suspense } from "react";
import { SearchResults } from "./search-results";

export default function SearchPage() {
  return (
    <main>
      <h1>Zoekresultaten</h1>
      <Suspense fallback={<p>Zoeken...</p>}>
        <SearchResults />
      </Suspense>
    </main>
  );
}

// app/search/search-results.tsx
"use client";
import { useSearchParams } from "next/navigation";

export function SearchResults() {
  const params = useSearchParams();
  const query = params.get("q") ?? "";
  return <p>Resultaten voor: {query}</p>;
}

Door de Client Component in een eigen bestand te isoleren en te wrappen, blijft de rest van je route statisch renderable. De officiële Next.js documentatie over deze error beschrijft dit als de canonieke oplossing.

Geneste Suspense en parallel data-fetching

Suspense boundaries nesten. Een parent-boundary kan een fallback tonen terwijl een child-boundary onafhankelijk daarvan een eigen fallback rendert. Dit is krachtig maar ook een veelvoorkomende bron van verwarring, vooral als je per ongeluk je data-fetches sequentieel maakt.

Vergelijk deze twee implementaties:

// SEQUENTIEEL - fout patroon
export async function UserDashboard({ userId }: { userId: string }) {
  const user = await getUser(userId);           // 200 ms
  const orders = await getOrders(userId);       // 400 ms
  const recommendations = await getRecs(userId); // 300 ms
  return <Dashboard user={user} orders={orders} recs={recommendations} />;
  // Totaal: 900 ms voordat er iets rendert
}
// PARALLEL + STREAMING - correct patroon
import { Suspense } from "react";

export function UserDashboard({ userId }: { userId: string }) {
  return (
    <>
      <Suspense fallback={<UserSkeleton />}>
        <UserHeader userId={userId} />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <UserOrders userId={userId} />
      </Suspense>
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations userId={userId} />
      </Suspense>
    </>
  );
  // De user-header verschijnt na 200 ms, orders na 400 ms, recs na 300 ms
  // First paint: ongeveer 200 ms in plaats van 900 ms
}

Als je binnen één component wél meerdere queries nodig hebt en die parallel wilt uitvoeren, gebruik Promise.all(). Dan wachten ze samen maar niet sequentieel. Combineer dit met een Suspense boundary voor het beste van twee werelden. Voor caching-strategieën die deze queries onder een gedeelde use cache directive plaatsen, zie mijn caching-gids voor Next.js 15.

Suspense en Error Boundaries samen

Een suspending component kan ook errors gooien: een failed fetch, een timeout, een 500 van je backend. React's <ErrorBoundary> (of Next.js' error.tsx) vangt die af zonder de rest van de pagina neer te halen. Plaats error-boundaries op hetzelfde niveau als je Suspense-boundaries voor granulaire fault-isolation:

// app/dashboard/orders-section/page.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "@/components/error-boundary";

<ErrorBoundary fallback={<OrdersError />}>
  <Suspense fallback={<OrdersSkeleton />}>
    <RecentOrders />
  </Suspense>
</ErrorBoundary>

Meer diepgang in mijn gids over foutafhandeling met error.tsx.

Streaming performance meten met DevTools

Streaming voelt goed, maar "voelt goed" is geen metric. Gebruik Chrome DevTools om te bewijzen dat je de winst haalt die je denkt te halen. Ik open standaard drie panels: Network, Performance, en de Rendering pane met "Paint flashing" aan.

Network tab: TTFB en chunked responses

Klik op je document-request. Onder Timing zie je "Waiting for server response" (TTFB) en "Content download". Bij streaming zou je TTFB laag moeten zijn (het skelet komt snel) en de download-tijd langer (de chunks komen na elkaar binnen). Als je TTFB nog steeds 1000+ ms is, block je waarschijnlijk ergens in je layout of page-shell. Check op accidentele await calls buiten een Suspense boundary.

Performance panel: LCP en TBT vergelijken

Neem een trace op van een cold navigation. Kijk naar de LCP marker in de timeline: dat is het moment waarop het grootste content-element geverfd wordt. Vergelijk before/after je Suspense-refactor. In mijn dashboard-case ging LCP van 1420 ms naar 340 ms (76% winst) door drie widgets in eigen boundaries te wrappen. Total Blocking Time daalde van 180 ms naar 40 ms omdat React niet meer één grote hydration-taak had.

React Profiler: hydration prioritering inspecteren

De React DevTools Profiler laat zien wanneer welke component hydrateert. Bij streaming zie je meerdere kleine commit-blokken in plaats van één grote, dat is progressive hydration in actie. Klik op een suspending boundary in de tree en de profiler markeert de bijbehorende commits.

Veelvoorkomende fouten

1. Een await buiten een Suspense in je layout

Elke await in layout.tsx of buiten een boundary in page.tsx blokkeert de volledige streaming pipeline. Als je layout wacht op een auth-check die 300 ms duurt, verschijnt er in die 300 ms geen enkele HTML. Verplaats de check naar een boundary of naar middleware.

2. Data-fetching in de page-component in plaats van in het kind

Als je alle data in de page-component fetcht en via props doorgeeft, kan React niet weten welke onderdelen apart mogen streamen. De hele page suspends als één blok. Fetch data in de component die het rendert.

3. Sequentiële awaits binnen één component

Drie awaits achter elkaar met onafhankelijke queries is een 900 ms wachtketen. Gebruik Promise.all() of splits in aparte suspending children.

4. Skeletons met verkeerde dimensies

Een fallback die 100 px hoog is voor content die 320 px hoog wordt, veroorzaakt een layout shift van 220 px. Google's Core Web Vitals rekent dat als CLS-schuld. Meet en match.

5. <Suspense> vergeten om useSearchParams()

Zoals hierboven besproken: dit is een build-error in output: 'export' en een silent CSR-bailout in server-mode. Wrap altijd.

6. Denken dat streaming je API sneller maakt

Streaming verlaagt perceived latency, niet actual latency. Je database-query duurt nog steeds even lang. Als je fundamenteel te trage queries hebt, los eerst dát op (indexen, caching, ORM-N+1) voordat je streaming als pleister gebruikt. Voor deep-caching zie de officiële Next.js streaming guide en de React Suspense reference documentation.

Veelgestelde vragen

Wat is het verschil tussen loading.tsx en Suspense in Next.js?

loading.tsx is een file-system convention die Next.js automatisch omzet in een <Suspense> rond je hele route-segment. Handmatige <Suspense> boundaries geven je fijnmazige controle over welke component-secties onafhankelijk mogen streamen. Je gebruikt ze vaak samen.

Werkt streaming in Next.js op de Edge Runtime?

Ja. De Edge Runtime gebruikt renderToReadableStream in plaats van renderToPipeableStream, maar het contract is identiek. Streaming werkt op zowel Node.js als Edge zonder code-aanpassingen, mits je hosting-provider (Vercel, Cloudflare, Netlify) chunked responses ondersteunt.

Vertraagt Suspense mijn Time to First Byte?

Nee, integendeel. Suspense zorgt ervoor dat Next.js het statische deel van je pagina direct kan versturen zonder te wachten op async data. Je TTFB daalt vaak van 500-1500 ms naar 40-100 ms, zolang je layout en page-shell geen blocking awaits bevatten.

Kan ik Suspense gebruiken zonder Server Components?

Ja, maar de winst is beperkter. React 18+ ondersteunt Suspense voor client-side data-libraries als SWR, TanStack Query en Relay. In een Client Component context krijg je progressive rendering na hydratie, terwijl Server Components streaming al tijdens de initiële HTML-response toepassen.

Hoe combineer ik Suspense met Partial Prerendering (PPR)?

PPR bouwt op precies dit patroon: de statische shell wordt bij build-time gerenderd, elke <Suspense> boundary markeert een dynamische hole die per request wordt gestreamd. Als je je pagina al goed hebt opgedeeld met Suspense, is PPR aanzetten via experimental.ppr in next.config.js vaak een one-liner met directe TTFB-winst.

Oliver Schmidt
Over de Auteur Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.