// app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowTable, FastHeader, TableSkeleton } from '@/components';
export default function DashboardPage() {
return (
<div className="p-6">
<FastHeader /> {/* Se randează instant pe server și pleacă imediat spre client */}
<main className="mt-8">
<Suspense fallback={<TableSkeleton height={400} />}>
{/* @ts-expect-error Server Component */}
<SlowTable />
</Suspense>
</main>
</div>
);
}Să fim serioși, când a apărut Next.js App Router, mulți am trântit un loading.tsx în folder și am crezut că am rezolvat problema de UX. Am făcut exact asta acum un an la un dashboard pentru un client din zona de logistică, cu vreo 12.000 de utilizatori activi zilnic. Rezultatul? Un festival de layout shifts și clienți stresați care dădeau click de trei ori pe același buton pentru că „nu se întâmpla nimic”.
Atunci am înțeles că loading.tsx și <Suspense>-ul granular din React sunt două fiare complet diferite, deși sub capotă folosesc aceeași tehnologie.
Măciuca numită loading.tsx
În Next.js, loading.tsx este practic un <Suspense> automat care îmbracă întreaga pagină (sau mai exact, ruta respectivă). Este extrem de comod de implementat. Nu scrii niciun rând de cod de gestiune a stării, doar pui fișierul acolo și gata.
Dar aici apare și problema. Dacă ai o pagină cu un header rapid, un sidebar static și un singur tabel lent care trage date din trei API-uri legacy, loading.tsx va bloca vizual absolut tot. Utilizatorul vede un ecran gol de încărcare (sau un skeleton masiv) timp de 3 secunde, deși 90% din pagină era gata de afișat instant.
Când cobori în tranșee cu Suspense granular
Ca să rezolv problema de pe dashboard-ul logistic, am aruncat loading.tsx din ruta principală și am spart pagina în componente Server Components asincrone independente.
Ideea e simplă: lași scheletul paginii (layout-ul, meniul de navigare, titlurile) să se randeze instant pe server și să plece spre client. Componentele grele le îmbraci individual în <Suspense> direct în codul paginii.
Am reușit să reduc LCP (Largest Contentful Paint) cu 1.4 secunde doar prin această mutare. Utilizatorul vedea instant meniul și structura paginii, iar tabelele se încărcau pe rând, pe măsură ce veneau datele.
Streaming SSR și un trade-off destul de dureros
Toată magia asta se bazează pe Streaming SSR. Serverul nu mai așteaptă să termine toate query-urile de bază de date înainte să trimită HTML-ul către browser. Trimite bucățile gata, iar pentru cele care încarcă date trimite un placeholder (fallback-ul din Suspense). Când promisiunea de date se rezolvă, Serverul trimite bucata lipsă de HTML prin același stream HTTP deschis, iar React o injectează la locul ei.
Sună perfect, dar am dat de un trade-off major: Layout Shift-ul.
Dacă scheletul tău de loading are 100px înălțime, iar tabelul final are 500px, când vin datele tot conținutul de sub tabel va fi împins violent în jos. Google te va penaliza la scorul CLS (Cumulative Layout Shift) de o să te doară capul.
Cum am rezolvat asta? Fiecare componentă de tip Skeleton trebuie să aibă dimensiuni fixe (sau min-height) cât mai apropiate de componenta finală. Nu e perfect, e muncă manuală de design, dar e singura cale de a avea un streaming fluid.
Concluzia mea
Regula mea de aur acum este simplă:
- Folosesc
loading.tsxdoar la nivel macro, pentru tranziții mari între pagini complet diferite. - Folosesc
<Suspense>granular pentru orice widget, tabel sau secțiune dintr-o pagină care depinde de un API extern sau de un query SQL lent.
Voi cum gestionați înălțimile scheletelor ca să evitați layout shift-ul când faceți streaming? Mergeți pe dimensiuni fixe sau aveți vreo metodă mai dinamică?