INTERMEDIATENEXTJSPERFORMANCE

Streaming Server Components with Suspense and Skeletons

Use React Suspense in Next.js Server Components to stream slow data fetches with skeleton fallbacks instead of blocking the entire page.

By Tested on Next.js 16, React 19

<Suspense> boundaries in Server Components let Next.js stream slow async children separately from the rest of the page. Wrap any expensive data fetch in a Suspense boundary with a skeleton fallback, and the user sees the page shell + skeletons immediately while the slow data loads in the background. This is the App Router replacement for client-side loading spinners.

Tested on Next.js 16, React 19.

When to Use This

  • A page with one slow data source (e.g., third-party API) and many fast sources
  • Dashboards with multiple independent widgets
  • Comment sections that load slower than the article above them
  • Anywhere you'd otherwise show a full-page loading spinner

Don't use this when every section depends on the same data (Suspense buys you nothing) or when the slow part is critical for SEO (search engines see the fallback, not the streamed content, until they execute the JS).

Code

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardSkeleton, ChartSkeleton } from '@/components/skeletons';
 
export default function DashboardPage() {
  return (
    <main className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-8">
      {/* Fast — renders immediately */}
      <section>
        <h1 className="text-3xl font-bold mb-4">Welcome back</h1>
        <p className="text-muted-foreground">
          Here is what is happening in your account today.
        </p>
      </section>
 
      {/* Slow — streams in once ready */}
      <Suspense fallback={<DashboardSkeleton />}>
        <RecentActivity />
      </Suspense>
 
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
 
      <Suspense fallback={<ChartSkeleton />}>
        <TopProducts />
      </Suspense>
    </main>
  );
}
 
async function RecentActivity() {
  // This await blocks ONLY this Suspense boundary, not the whole page
  const events = await fetch('https://slow-api.example.com/events').then((r) => r.json());
  return (
    <div className="rounded border p-4">
      <h2 className="font-semibold mb-2">Recent activity</h2>
      <ul>
        {events.map((e: { id: string; text: string }) => (
          <li key={e.id}>{e.text}</li>
        ))}
      </ul>
    </div>
  );
}

Each <Suspense> boundary is independent. RecentActivity, RevenueChart, and TopProducts all fetch in parallel and stream in as soon as each is ready.

Usage

The skeleton components are simple presentational pieces — no data, no logic:

// components/skeletons.tsx
export function DashboardSkeleton() {
  return (
    <div className="rounded border p-4 animate-pulse">
      <div className="h-4 bg-muted rounded w-1/3 mb-3" />
      <div className="space-y-2">
        <div className="h-3 bg-muted rounded w-full" />
        <div className="h-3 bg-muted rounded w-5/6" />
        <div className="h-3 bg-muted rounded w-4/6" />
      </div>
    </div>
  );
}
 
export function ChartSkeleton() {
  return (
    <div className="rounded border p-4 animate-pulse">
      <div className="h-4 bg-muted rounded w-1/4 mb-4" />
      <div className="h-40 bg-muted rounded" />
    </div>
  );
}

The animate-pulse Tailwind class gives them a subtle shimmer so users can see the page is alive.

Caveats

  • Suspense only catches Promises, not regular slow code. A synchronous slow loop blocks the entire request. Only await and use() get caught by Suspense.
  • Don't put a Suspense boundary at the top of your tree. That defeats the purpose — you want as much HTML as possible streaming first, then the slow children.
  • Streaming responses are NOT cached by intermediate proxies. Your CDN may not cache them at all. Test with your CDN if caching matters.
  • loading.tsx is the file convention for top-level page loading. It wraps the entire page in a Suspense boundary automatically. Use it for "the whole page is slow" cases instead of manual <Suspense>.
  • Errors inside Suspense bubble to the nearest error.tsx. Add an error boundary alongside your Suspense to handle fetch failures gracefully.
  • Server Components don't render twice. If you log inside an async Server Component, it logs once on the server. There is no client-side hydration log.

Frequently Asked Questions

What is streaming in Next.js Server Components?

Streaming sends parts of the page to the browser as soon as they're ready instead of waiting for the entire page to render. Wrap any slow async Server Component in <Suspense>, give it a fallback, and Next.js streams the fallback first, then swaps it for the real content when the data is ready.

Does streaming work without JavaScript on the client?

Yes. Streaming uses HTTP chunked transfer encoding plus React's progressive HTML hydration. The fallback shows immediately, then the actual content replaces it as the server pushes more chunks. Even with JS disabled, the user sees the final content (just without interactivity).

X (Twitter)LinkedIn