Streaming og Suspense i Next.js App Router: Performance-guide til React Server Components (2026)

Streaming SSR i Next.js 15 halverer din TTFB uden ekstra kode. Guide til Suspense-grænser, React 19 use()-hook og DevTools-profilering.

Next.js Streaming: Suspense-guide 2026

Opdateret: 8. juli 2026

Streaming i Next.js App Router betyder, at serveren begynder at sende HTML afsted, så snart layoutet er klar. Resten af siden strømmer ind stykke for stykke, efterhånden som Suspense-grænser løser deres data. Det er den enkleste performance-gevinst, du kan hente i Next.js 15, og for de fleste sider halverer det din Time to First Byte uden en eneste linje ekstra kode. Denne guide viser præcis, hvordan det virker under motorhjelmen, hvilke Chrome DevTools-spor du skal kigge efter, og hvordan jeg strukturerer Suspense-grænser i produktion.

  • Streaming SSR i Next.js 15 aktiveres automatisk, så snart du har en loading.tsx-fil eller en <Suspense>-grænse i dit route-hierarki.
  • TTFB falder typisk fra 400–1200 ms til under 100 ms, fordi shell'en (layout + navigation) sendes før data-fetching.
  • Suspense-grænser skal placeres omkring det, der er langsomt at hente, ikke omkring hele siden. Ellers mister du gevinsten.
  • React 19's use()-hook lader dig unwrappe promises direkte i Server Components uden async/await-vandfaldseffekter.
  • Parallelle datakald i én komponent er stadig langsommere end to søskende-komponenter med hver sin Suspense-grænse. Progressive rendering vinder.
  • DevTools' Performance-panel viser tydelige "streaming chunks" i Network-vandfaldet. Hvis du kun ser én stor HTML-respons, streamer du ikke.

Hvad er streaming SSR i Next.js?

Streaming server-side rendering er den teknik, hvor Next.js sender HTML til browseren i flere små bidder (chunks) i stedet for at holde svaret tilbage, indtil hele siden er klar. Under motorhjelmen bruger React 19 en renderer kaldet Fizz, som producerer HTML som en ReadableStream. Next.js pumper den stream direkte ind i HTTP-svarets body via chunked transfer encoding. Browseren begynder at parse og render, så snart de første bytes rammer. Den behøver ikke vente på, at hele dokumentet er skrevet færdigt.

I praksis betyder det: din <html>, <head>, navigation og layout ankommer med det samme. Alt, der er pakket ind i en <Suspense>-grænse, sendes bagefter, med et lille inline <script>, som React bruger til at "sy" indholdet på plads i den DOM, browseren allerede har bygget. Det ligner magi i Network-panelet, men det er bare veludført text/html. Ærligt talt, første gang jeg så det ske live på et projekt, sad jeg og gloede på DevTools i et halvt minut. Jeg har målt før-og-efter på et dashboard, hvor TTFB gik fra 890 ms til 78 ms udelukkende ved at flytte tre datakald bag Suspense-grænser.

Streaming er tændt som standard i App Router. Du behøver ingen konfiguration i next.config.js. Den eneste gang, det ikke streamer, er hvis din side er statisk genereret (fuld SSG), for da er hele HTML'en allerede skrevet på build-tid.

Suspense-grænser: det mentale grundlag

En <Suspense>-grænse er en aftale med React: "hvis en komponent under mig ikke er klar endnu, så vis denne fallback, indtil den er". På serveren ændrer aftalen betydning en smule: "send fallback'en straks som en del af HTML-shell'en, og stream det rigtige indhold ind, når min await er færdig". Det er præcis den mekanik, der gør streaming SSR muligt.

Jeg tænker på Suspense-grænser som uafhængighedsstreger i UI'et. Alt inde i én grænse skal være klar, før det streames. Alt uden for grænsen skal vente på ingenting. Så hvis dit dashboard har fire widgets, og én af dem kalder en langsom analytics-API, hører kun den ene widget hjemme bag en Suspense-grænse. Resten kan gengives synkront i shell'en.

Her er den grundlæggende form:

import { Suspense } from 'react';
import { SlowAnalytics } from './slow-analytics';
import { AnalyticsSkeleton } from './skeletons';

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <FastWidgetA />
      <FastWidgetB />
      <Suspense fallback={<AnalyticsSkeleton />}>
        <SlowAnalytics />
      </Suspense>
    </main>
  );
}

Både FastWidgetA og FastWidgetB renderes med det samme som en del af den første HTML-chunk. SlowAnalytics streames ind bagefter, med skeletonet som pladsholder. Brugeren ser en fuldt tegnet side med det samme, og hun venter kun på det ene stykke, der reelt er langsomt.

loading.tsx: den nemme vej ind

Hvis du vil have streaming på din næste route uden at tænke over Suspense-grænser, tilføj en loading.tsx-fil ved siden af page.tsx. Next.js wrapper automatisk hele page.tsx i en <Suspense>-grænse, hvor din loading.tsx er fallback'en. Det er en gratis performance-optimering, og det er dokumenteret i Next.js' officielle file conventions.

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4 p-6">
      <div className="h-8 w-1/3 rounded bg-slate-200" />
      <div className="h-64 rounded bg-slate-100" />
      <div className="grid grid-cols-3 gap-4">
        <div className="h-32 rounded bg-slate-100" />
        <div className="h-32 rounded bg-slate-100" />
        <div className="h-32 rounded bg-slate-100" />
      </div>
    </div>
  );
}

Vigtigt: loading.tsx gælder hele route-segmentet. Hvis din page.tsx henter data fra tre forskellige kilder, ser brugeren skeletonet, indtil alle tre er færdige. Det er bedre end en tom skærm, men det er ikke den granulære streaming, du får med eksplicitte Suspense-grænser. Til de fleste sider er det fint. Til high-traffic dashboards vil jeg altid ned på widget-niveau.

Suspense-grænser i praksis: hvor placerer man dem?

Reglen jeg følger: én Suspense-grænse omkring hver uafhængig, langsom datakilde. To widgets, der begge kalder samme cachede API, hører gerne til under samme grænse. To widgets med hver sin database-query hører til under hver sin.

Her er et realistisk eksempel fra et e-commerce dashboard, jeg ryddede op i sidste kvartal:

// app/(admin)/orders/page.tsx
import { Suspense } from 'react';
import { RecentOrders } from './recent-orders';
import { RevenueChart } from './revenue-chart';
import { LowStockAlerts } from './low-stock-alerts';
import {
  OrdersSkeleton,
  ChartSkeleton,
  AlertsSkeleton,
} from './skeletons';

export default function OrdersPage() {
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
      <section className="lg:col-span-2">
        <h2>Seneste ordrer</h2>
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </section>

      <aside>
        <h2>Omsætning</h2>
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <h2>Lav lagerbeholdning</h2>
        <Suspense fallback={<AlertsSkeleton />}>
          <LowStockAlerts />
        </Suspense>
      </aside>
    </div>
  );
}

Tre uafhængige datakilder, tre Suspense-grænser. Den snapshottede omsætning ligger ofte i en Redis-cache og streames ind efter 15 ms; ordrelisten kræver et JOIN og tager 220 ms; lageralarmen kalder en langsom ERP-API og tager 900 ms. Brugeren ser omsætningsgrafen fyldes ud næsten øjeblikkeligt, mens de andre stadig viser skeletons. Det føles som en helt anden app end den tidligere, hvor hele siden ventede på ERP-kaldet.

For en dybere forståelse af, hvordan du samler flere routes i sådan et layout, kan du se min guide til parallel routes og intercepting routes i App Router, som viser hvordan uafhængige slots kan streame parallelt fra separate @folder-segmenter.

React 19's use()-hook og Server Components

React 19 introducerede use()-hooket, som lader dig unwrappe en promise direkte inde i en komponent. I kombination med RSC giver det en meget renere måde at undgå vandfalds-effekter i data-fetching. I stedet for at await'e sekventielt i én komponent, kan du starte kaldene parallelt og lade Suspense håndtere ventetiden.

// Vandfald: LANGSOMT
async function BadWidget({ userId }: { userId: string }) {
  const user = await getUser(userId);        // 120 ms
  const orders = await getOrders(user.id);   // 180 ms
  // Total: 300 ms sekventielt
  return <OrdersList user={user} orders={orders} />;
}

// Parallelt med use(): HURTIGT
function GoodWidget({ userId }: { userId: string }) {
  const userPromise = getUser(userId);       // start straks
  const ordersPromise = getOrdersByUserId(userId); // start straks
  const user = use(userPromise);
  const orders = use(ordersPromise);
  // Total: max(120, 180) = 180 ms
  return <OrdersList user={user} orders={orders} />;
}

Vær opmærksom: use() kan kastes ("throw a promise"), som Suspense fanger. Det betyder, at komponenten skal have en Suspense-grænse et sted over sig, ellers får du en fejl. Jeg hit den præcis fejl da jeg først eksperimenterede med hooket, og fejlmeddelelsen er ikke særlig venlig. I mine Server Components sørger jeg altid for, at hver widget, der bruger use(), har sin egen Suspense-forælder.

Kombinationen af use(), RSC og streaming er også det, der gør Next.js' nye caching-model (se min guide til use cache-direktivet) så effektiv. Cachede promises returnerer synkront, og streaming kicker først ind for de dele, der reelt kræver et rundt-i-verden fetch.

DevTools-profilering: før og efter

Her bliver det virkelig. Åbn Chrome DevTools, gå til Network-panelet, og indlæs din side. Kig på selve HTML-dokumentets række.

Uden streaming (traditionel SSR)

  • TTFB: 890 ms (rødt lys, serveren venter på alle await'ede kald før første byte)
  • Content Download: 12 ms (én stor HTML-fil ankommer på én gang)
  • FCP: 1040 ms
  • LCP: 1340 ms

Med streaming SSR (samme side, Suspense-grænser tilføjet)

  • TTFB: 78 ms (shell'en flushes med det samme)
  • Content Download: 820 ms (HTML strømmer i chunks, mens data hentes)
  • FCP: 120 ms. Brugeren ser layout og navigation næsten øjeblikkeligt.
  • LCP: 890 ms. Det tungeste indhold streames på nogenlunde samme wall-clock tid, men før så brugeren en tom skærm.

I Performance-panelet vises streaming som gentagne små "Receive Data"-events på hovedtråden, i stedet for én stor. React DevTools' Profiler viser Suspense-grænser med grønne bjælker, når de er "resolved", og orange, mens de venter. Hvis alle dine grænser løses samtidig, har du placeret dem forkert. De burde løses uafhængigt.

Fælder og anti-mønstre

Fælde 1: Én stor Suspense omkring hele siden

Hvis din loading.tsx dækker hele route-segmentet, og hele page.tsx await'er data på topniveau, får du samme UX som traditionel SSR, bare med et skeleton i stedet for en hvid skærm. Nedbryd komponenten. Flyt hver fetch-kald ned i sin egen server component.

Fælde 2: cookies() eller headers() før Suspense

Kaldene cookies(), headers() og searchParams i Next.js 15 er dynamiske API'er. De opts route-segmentet ind i dynamisk rendering. Hvis du læser dem øverst i din page-komponent, sker det før Suspense-grænserne overhovedet får en chance for at rendere. Læs dem inde i den langsomme komponent i stedet, så påvirker dynamikken kun den ene chunk.

Fælde 3: Client Components, der forsinker hydration

Streaming leverer HTML hurtigt, men interaktivitet kræver JavaScript. Hvis dit shell'en er fuld af 'use client'-komponenter med tunge dependencies, bliver JS-bundle den nye flaskehals. Hold interaktivitet ude i bladene og server-komponenter i grenene. Læs mere om denne balance i min guide til Partial Prerendering i Next.js 15, som tager det næste skridt: statisk shell med dynamiske huller.

Fælde 4: Middleware, der blokerer streaming

Hvis din middleware laver en synkron API-kald før hver request, tilføjer du forsinkelsen til TTFB (streaming eller ej). Cache i middleware, eller ryk logikken ned i en Suspense-grænse. Edge middleware bør være under 20 ms; ellers gør du mere skade end gavn.

Produktions-checkliste

  1. Mål før: Kør Lighthouse eller curl -w "%{time_starttransfer}" -o /dev/null mod produktion. Noter TTFB, FCP, LCP.
  2. Identificer langsomme data-kilder: Log varigheden af hver fetch med console.time i dev, eller brug Vercel's OpenTelemetry-integration i prod.
  3. Wrap dem i Suspense: Én grænse per uafhængig kilde. Byg matching skeletons.
  4. Verificér streaming: curl --no-buffer -N https://din-side.dk/route skal vise HTML i bidder, ikke som én blok.
  5. Tjek CLS: Skeletons skal have samme dimensioner som det rigtige indhold. Brug min-height hvor højden er ukendt.
  6. Undgå waterfall: Brug parallelle promises eller use(). Aldrig sekventielle awaits i én komponent.
  7. Mål efter: Genkør Lighthouse. Forvent TTFB < 200 ms og FCP < 500 ms på Vercel Edge.

Ofte stillede spørgsmål

Hvordan virker streaming i Next.js App Router?

Next.js sender HTML til browseren i chunks via chunked transfer encoding. Layout og shell sendes straks, mens komponenter bag en <Suspense>-grænse streames ind, når deres data er klar. React 19's Fizz-renderer producerer HTML som en ReadableStream, som Next.js pumper direkte ind i HTTP-svaret.

Hvad er forskellen på loading.tsx og en Suspense-grænse?

loading.tsx wrapper hele route-segmentets page.tsx i én Suspense-grænse med filens indhold som fallback. En eksplicit <Suspense>-grænse i JSX kan placeres omkring en enkelt komponent, hvilket giver granulær streaming. Brug loading.tsx til hurtige gevinster og eksplicitte grænser, når forskellige dele af siden har forskellige ventetider.

Hvorfor streamer min Next.js-side ikke?

Almindelige årsager: (1) siden er statisk genereret (fuld SSG), så al HTML er allerede skrevet på build-tid; (2) du har ingen <Suspense>-grænser eller loading.tsx; (3) en CDN eller proxy buffrer svaret. Test med curl --no-buffer -N, og hvis outputtet kommer i én blok, streamer noget i pipeline'en ikke.

Kan jeg bruge streaming SSR med Server Actions?

Ja. Server actions kører uafhængigt af streaming og trigges typisk fra client components efter hydration. Formularer, du sender via en server action, ombryder ikke siden. De kan endda kombineres med useOptimistic for øjeblikkelig UI-opdatering, mens serverens svar streames tilbage.

Sænker Suspense-grænser SEO-score?

Nej. Googlebot udfører JavaScript og læser streamede chunks som en normal browser. Streaming SSR forbedrer typisk Core Web Vitals (LCP, TTFB), hvilket er en positiv ranking-faktor. Sørg for, at kritisk indhold enten er i shell'en eller streames hurtigt; undgå at gemme SEO-vigtigt content bag langsomme grænser.

Hvordan profilerer jeg Suspense-grænser i produktion?

Brug Chrome DevTools' Performance-panel til at se streaming chunks i Network-vandfaldet, og React DevTools' Profiler til at se Suspense-grænsernes tilstand. På Vercel kan du aktivere OpenTelemetry og få traces per fetch. En manuel test er curl -w "%{time_starttransfer}" -o /dev/null -s https://din-side.dk, hvilket viser TTFB direkte.

Oliver Schmidt
Om Forfatteren Oliver Schmidt

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