Streaming și Suspense în Next.js 16: Ghid Complet cu Loading UI și Partial Prerendering

Află cum să implementezi streaming, Suspense și loading UI în Next.js 16. Ghid practic cu Partial Prerendering (PPR), loading skeletons, Error Boundaries și optimizări de performanță cu exemple de cod.

Dacă ai urmărit seria noastră despre Next.js 16, ai văzut deja cum funcționează Cache Components cu use cache, cum gestionezi Server Actions cu validare Zod și cum ai construit operațiuni CRUD cu Drizzle ORM. Acum vine o piesă fundamentală din puzzle: cum faci ca aplicația ta să se simtă rapidă, chiar și atunci când datele din spate au nevoie de timp să se încarce.

Sincer, asta e una dintre problemele care m-au frustrat cel mai mult în trecut. Utilizatorul dă click, și... nimic. Ecran alb. Secondele alea par o eternitate.

Răspunsul se numește streaming. În loc să aștepți ca serverul să genereze tot HTML-ul și să-l trimită într-un singur bloc masiv, streaming-ul permite trimiterea progresivă a fragmentelor de interfață pe măsură ce devin disponibile. Utilizatorul vede instant un layout, un header, un skeleton — iar restul conținutului apare fluid, fără acel moment frustrant de ecran gol.

În Next.js 16, streaming-ul vine construit nativ în App Router prin React Suspense, fișierul loading.tsx și arhitectura Partial Prerendering (PPR). Hai să vedem cum le combini corect pentru performanță maximă.

Ce este streaming-ul în Next.js 16?

Streaming-ul e o tehnică de randare unde serverul trimite fragmente de HTML către browser pe măsură ce fiecare componentă finalizează randarea — fără a aștepta ca toate datele să fie gata. Din perspectiva utilizatorului, pagina se încarcă progresiv: mai întâi apare structura (navbar, sidebar, skeleton-uri), apoi conținutul dinamic vine treptat pe măsură ce datele sosesc de la server.

Diferența față de SSR-ul tradițional e destul de mare:

  • SSR clasic: Serverul randează întreaga pagină, așteptând toate query-urile de baze de date, apoi trimite tot HTML-ul odată. Time to First Byte (TTFB) mare.
  • Streaming: Serverul trimite HTML-ul static imediat, iar componentele dinamice vin pe rând, pe măsură ce finalizează. TTFB scăzut cu 40-60%.

Ca să-ți faci o idee concretă — într-un test pe un dashboard cu date complexe, TTFB-ul a scăzut de la 350-550ms la 40-90ms după implementarea streaming-ului. Asta e o diferență pe care utilizatorii chiar o simt.

loading.tsx — streaming automat prin convenție de fișier

Cel mai simplu mod de a activa streaming-ul e prin fișierul special loading.tsx. Îl plasezi în orice director de rută din App Router, iar Next.js va afișa automat conținutul său ca stare de loading în timp ce pagina se încarcă. Simplu, nu?

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

Ce se întâmplă în spatele scenei: Next.js învelește automat conținutul paginii (page.tsx) într-un <Suspense> boundary cu fallback-ul din loading.tsx. Skeleton-ul e inclus în fișierul static și e trimis imediat, iar conținutul dinamic vine apoi prin streaming.

Un avantaj pe care mulți îl ignoră: navigarea e prefetch-uită și instantă. Dacă utilizatorul navighează la altă pagină înainte ca streaming-ul să se finalizeze, navigarea nu e blocată — este interruptibilă.

Când să folosești loading.tsx

Fișierul loading.tsx e ideal pentru:

  • Pagini întregi care au nevoie de un loading state (dashboard-uri, pagini de listă)
  • Situații simple unde tot conținutul depinde de aceeași sursă de date
  • Prototipare rapidă — adaugi un fișier și ai streaming funcțional instant

Dar atenție la limitare: loading.tsx acoperă întreaga pagină. Dacă ai o componentă rapidă și una lentă pe aceeași pagină, ambele vor fi ascunse până dispare loading-ul. Pentru control mai fin, ai nevoie de Suspense manual.

Suspense boundaries — control granular al streaming-ului

Aici lucrurile devin cu adevărat interesante. React <Suspense> îți oferă control precis asupra ce părți din pagină sunt transmise prin streaming independent. Nu mai aștepți totul — definești exact ce apare imediat și ce se încarcă progresiv.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { StatsCards } from "@/components/stats-cards";
import { RecentOrders } from "@/components/recent-orders";
import { RevenueChart } from "@/components/revenue-chart";
import { StatsSkeleton, OrdersSkeleton, ChartSkeleton } from "@/components/skeletons";

export default function DashboardPage() {
  return (
    <div className="space-y-6 p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      {/* Statisticile se încarcă rapid — apar primele */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsCards />
      </Suspense>

      <div className="grid grid-cols-2 gap-6">
        {/* Comenzile și graficul se încarcă independent */}
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>

        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
      </div>
    </div>
  );
}

Fiecare Server Component poate avea propriul async data fetch. Partea frumoasă e că React le execută în paralel — nu secvențial. Prima componentă care termină apare imediat, indiferent de ordinea din cod.

// components/stats-cards.tsx (Server Component)
import { db } from "@/db";

export async function StatsCards() {
  // Acest query rulează pe server — nu trimite JS către client
  const stats = await db.query.orders.findMany({
    columns: { total: true, status: true },
    where: (orders, { gte }) =>
      gte(orders.createdAt, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)),
  });

  const totalRevenue = stats.reduce((sum, o) => sum + o.total, 0);
  const completedOrders = stats.filter((o) => o.status === "completed").length;

  return (
    <div className="grid grid-cols-3 gap-4">
      <StatCard title="Venituri" value={`${totalRevenue} RON`} />
      <StatCard title="Comenzi" value={stats.length} />
      <StatCard title="Finalizate" value={completedOrders} />
    </div>
  );
}

Regula de plasare a Suspense boundaries

O regulă practică pe care o respect mereu: mută fetch-urile de date cât mai aproape de componentele care le folosesc, apoi învelește acele componente în <Suspense>. E exact strategia recomandată de echipa Next.js, și funcționează excelent.

Evită cele două extreme:

  • Prea puține boundaries: Întreaga pagină așteaptă cea mai lentă componentă — pierzi practic tot beneficiul streaming-ului.
  • Prea multe boundaries: Ajungi la „spinner soup" — o pagină plină de indicatoare de încărcare care apar și dispar haotic. Regula mea: maxim 2-3 boundaries pe secțiune vizuală.

Loading skeletons eficiente — nu doar spinner-e generice

Un skeleton loader bine făcut comunică structura conținutului care urmează. Și da, contează mai mult decât ai crede. Utilizatorii percep pagina ca fiind mai rapidă când văd forme familiare (carduri, linii de text, grafice) comparativ cu un simplu spinner rotativ.

// components/skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="rounded-lg border p-4 animate-pulse">
          <div className="h-4 w-20 bg-gray-200 rounded mb-2" />
          <div className="h-8 w-32 bg-gray-200 rounded" />
        </div>
      ))}
    </div>
  );
}

export function OrdersSkeleton() {
  return (
    <div className="rounded-lg border p-4 space-y-3 animate-pulse">
      <div className="h-6 w-40 bg-gray-200 rounded" />
      {[1, 2, 3, 4, 5].map((i) => (
        <div key={i} className="flex justify-between">
          <div className="h-4 w-48 bg-gray-200 rounded" />
          <div className="h-4 w-20 bg-gray-200 rounded" />
        </div>
      ))}
    </div>
  );
}

export function ChartSkeleton() {
  return (
    <div className="rounded-lg border p-4 animate-pulse">
      <div className="h-6 w-32 bg-gray-200 rounded mb-4" />
      <div className="h-48 bg-gray-200 rounded" />
    </div>
  );
}

Câteva reguli de bază pentru skeleton-uri care chiar funcționează:

  • Potrivește dimensiunile: Skeleton-ul trebuie să aibă aceleași dimensiuni ca și conținutul real. Altfel, obții Content Layout Shift (CLS) — iar asta distruge experiența.
  • Folosește animații subtile: animate-pulse din Tailwind e mai mult decât suficient. Evită animațiile complexe care consumă CPU degeaba.
  • Menține structura: Dacă conținutul real are 3 carduri, skeleton-ul trebuie să arate 3 forme dreptunghiulare, nu un singur bloc gri.

Partial Prerendering (PPR) — shell static + conținut dinamic prin streaming

Partial Prerendering e probabil cea mai importantă inovație din Next.js 16. Serios. PPR combină viteza paginilor statice cu flexibilitatea randării dinamice — totul într-o singură cerere HTTP.

Conceptul e elegant: la build time, Next.js pre-randează tot ce poate fi generat fără date de la request time (navbar, sidebar, layout, skeleton-uri). Această „coajă statică" e servită instant din CDN. Conținutul dinamic, marcat prin <Suspense> boundaries, vine apoi prin streaming imediat după, în aceeași cerere HTTP.

Activarea PPR în Next.js 16

Vestea bună: în Next.js 16, flag-ul experimental.ppr nu mai există. PPR face acum parte din arhitectura Cache Components. Activezi totul printr-o singură opțiune:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Odată activat, Next.js 16 funcționează într-un model dinamic implicit: tot codul e dinamic, iar tu optezi explicit în caching folosind directiva "use cache". Componentele care nu pot fi pre-randate trebuie învelite în <Suspense> pentru a fi amânate la request time.

Exemplu practic PPR cu Cache Components

// app/products/page.tsx
import { Suspense } from "react";
import { ProductGrid } from "@/components/product-grid";
import { ProductGridSkeleton } from "@/components/skeletons";

// Navbar și layout-ul sunt pre-randate la build time (shell static)
// ProductGrid este transmis prin streaming la request time

export default function ProductsPage() {
  return (
    <main className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Produse</h1>

      {/* Această componentă este dinamică — transmisă prin streaming */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
    </main>
  );
}
// components/product-grid.tsx
"use cache";
import { cacheLife } from "next/cache";
import { db } from "@/db";

export async function ProductGrid() {
  cacheLife("minutes"); // Cache 5 minute

  const products = await db.query.products.findMany({
    orderBy: (products, { desc }) => [desc(products.createdAt)],
    limit: 20,
  });

  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Rezultatul e exact ce-ți dorești: shell-ul static (header, titlu, skeleton) ajunge la utilizator în milisecunde din CDN. Grid-ul de produse apare imediat după, transmis prin streaming. Iar dacă datele sunt deja în cache (mulțumită "use cache"), și componenta dinamică apare aproape instant.

Streaming cu Error Boundaries — reziliență în producție

Ok, dar ce se întâmplă dacă un fetch eșuează în timpul streaming-ului? Fără protecție, întreaga pagină poate cădea. Am pățit-o — și nu e plăcut.

Soluția: învelește fiecare <Suspense> boundary într-un Error Boundary.

// components/error-boundary.tsx
"use client";

import { Component, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback: ReactNode;
}

interface State {
  hasError: boolean;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}
// Utilizare în pagină
import { ErrorBoundary } from "@/components/error-boundary";

<ErrorBoundary fallback={<p>Nu am putut încărca comenzile.</p>}>
  <Suspense fallback={<OrdersSkeleton />}>
    <RecentOrders />
  </Suspense>
</ErrorBoundary>

Sau, mai simplu, poți folosi fișierul error.tsx — care e practic echivalentul loading.tsx, dar pentru erori:

// app/dashboard/error.tsx
"use client";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="p-6 text-center">
      <h2 className="text-xl font-bold text-red-600">Ceva nu a mers bine</h2>
      <p className="mt-2 text-gray-600">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
      >
        Încearcă din nou
      </button>
    </div>
  );
}

Optimizări de performanță pentru streaming

Streaming-ul în sine îmbunătățește percepția vitezei, dar câteva optimizări suplimentare pot face o diferență serioasă.

1. Mută „use client" la nivelul frunzelor

Păstrează layout-urile și containerele ca Server Components. Adaugă "use client" doar pe componentele interactive de la nivelul cel mai de jos — butoane, formulare, dropdown-uri. Astfel maximizezi cantitatea de HTML trimisă prin streaming fără JavaScript suplimentar.

// ❌ Greșit — întregul layout devine Client Component
"use client";
export default function Dashboard({ children }) {
  return <div onClick={handleClick}>{children}</div>;
}

// ✅ Corect — doar butonul interactiv e Client Component
// components/action-button.tsx
"use client";
export function ActionButton() {
  return <button onClick={handleClick}>Acțiune</button>;
}

2. Preîncarcă datele critice cu cache

Folosește "use cache" din Next.js 16 pentru componente care nu se schimbă la fiecare request. Combinat cu PPR, conținutul cache-uit devine parte din shell-ul static — și asta face o diferență enormă:

// components/navigation.tsx
"use cache";
import { cacheLife } from "next/cache";
import { db } from "@/db";

export async function Navigation() {
  cacheLife("hours"); // Cache pentru ore — categoriile se schimbă rar

  const categories = await db.query.categories.findMany();

  return (
    <nav>
      {categories.map((cat) => (
        <a key={cat.id} href={`/category/${cat.slug}`}>{cat.name}</a>
      ))}
    </nav>
  );
}

3. Dezactivează buffering-ul în reverse proxy

Dacă deployezi pe un server propriu cu Nginx, asigură-te că streaming-ul ajunge la browser fără buffering. Altfel, toată munca ta de optimizare e degeaba:

# nginx.conf
location / {
  proxy_pass http://localhost:3000;
  proxy_buffering off;
  proxy_cache off;
  chunked_transfer_encoding on;
}

Pe Vercel, streaming-ul funcționează nativ — fără nicio configurare suplimentară.

4. Testează pe conexiuni lente

Un sfat care pare banal, dar face minuni: deschide Chrome DevTools → Network → selectează „Slow 3G". Diferența dintre o pagină cu streaming corect configurat și una fără e dramatică pe conexiuni lente. E cea mai bună validare pe care o poți face.

Lazy loading cu next/dynamic

Pe lângă streaming-ul Server Components, poți folosi next/dynamic pentru a încărca leneș Client Components mari — grafice, editoare de text, hărți — care nu sunt necesare la încărcarea inițială:

// app/dashboard/page.tsx
import dynamic from "next/dynamic";
import { Suspense } from "react";
import { ChartSkeleton } from "@/components/skeletons";

// Graficul se încarcă doar când e nevoie — nu blochează bundle-ul inițial
const RevenueChart = dynamic(
  () => import("@/components/revenue-chart-client"),
  {
    loading: () => <ChartSkeleton />,
    ssr: false, // Opțional: dacă graficul folosește window/document
  }
);

Un detaliu important: Server Components sunt automat code-split, deci nu ai nevoie de next/dynamic pentru ele. Folosește-l doar pentru Client Components voluminoase.

Întrebări frecvente

Care e diferența între loading.tsx și Suspense manual?

loading.tsx acoperă întreaga pagină (route segment) și se activează automat. <Suspense> manual îți permite să controlezi exact ce componente sunt transmise independent prin streaming. Pe scurt: loading.tsx pentru pagini simple, <Suspense> pentru dashboard-uri sau pagini cu componente independente.

Streaming-ul funcționează cu ISR sau doar cu SSR dinamic?

Funcționează cu ambele. Cu Partial Prerendering (PPR) în Next.js 16, shell-ul static e servit din cache/CDN, iar componentele dinamice sunt transmise prin streaming la request time. Practic combini viteza ISR cu flexibilitatea SSR-ului dinamic — best of both worlds.

Cum evit Content Layout Shift (CLS) cu loading skeletons?

Skeleton-urile trebuie să aibă exact aceleași dimensiuni ca și conținutul final. Folosește dimensiuni fixe (h-32, w-48) sau aspect ratio-uri consistente. Testează cu Lighthouse sau Web Vitals și asigură-te că CLS rămâne sub 0.1.

Pot folosi streaming pe Vercel fără configurare suplimentară?

Da, absolut. Vercel suportă streaming nativ pentru Next.js — atât pe Node.js runtime, cât și pe Edge. Zero configurare. Dacă deployezi pe un server propriu cu Nginx însă, trebuie să dezactivezi proxy_buffering (vezi secțiunea de mai sus).

Ce se întâmplă dacă o componentă din Suspense aruncă o eroare?

Fără Error Boundary, eroarea se propagă în sus și poate afecta întreaga pagină. De aceea, învelește fiecare <Suspense> într-un Error Boundary (sau folosește fișierul error.tsx) pentru a izola erorile și a afișa un mesaj de fallback — restul paginii rămâne funcțional.

Despre Autor Editorial Team

Our team of expert writers and editors.