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.
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:
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
Mål før: Kør Lighthouse eller curl -w "%{time_starttransfer}" -o /dev/null mod produktion. Noter TTFB, FCP, LCP.
Identificer langsomme data-kilder: Log varigheden af hver fetch med console.time i dev, eller brug Vercel's OpenTelemetry-integration i prod.
Wrap dem i Suspense: Én grænse per uafhængig kilde. Byg matching skeletons.
Verificér streaming:curl --no-buffer -N https://din-side.dk/route skal vise HTML i bidder, ikke som én blok.
Tjek CLS: Skeletons skal have samme dimensioner som det rigtige indhold. Brug min-height hvor højden er ukendt.
Undgå waterfall: Brug parallelle promises eller use(). Aldrig sekventielle awaits i én komponent.
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.
Komplet guide til Next.js 15 Metadata API i App Router. Lær generateMetadata til dynamisk SEO, dynamiske Open Graph-billeder med next/og, og type-sikre sitemap.ts og robots.ts med kodeeksempler.
Lær hvordan Partial Prerendering kombinerer statisk HTML-shell med streamede Suspense-grænser i Next.js 15, og se hvordan du sætter det op rute-for-rute med praktiske kodeeksempler.