EmpireUI
Get Pro
← Blog9 min read#streaming#next.js#suspense

Next.js Streaming: Suspense Boundaries, Loading UI, Partial Prerender

Master Next.js streaming with Suspense boundaries, loading.tsx files, and Partial Prerender — ship faster perceived load times without sacrificing data freshness.

developer coding Next.js streaming application on laptop screen

Why Streaming Matters More Than You Think

Let's be direct: a blank screen for 2–3 seconds is a conversion killer. Users bounce. You know this. The problem is that most Next.js apps still treat server rendering as a single monolithic operation — fetch everything, render everything, ship everything at once. That works fine when your data is fast. It's brutal when one slow query blocks the entire page.

Streaming flips that model. Instead of waiting for all data to resolve, you send the HTML shell immediately, then stream in the slower parts as they become ready. Browsers handle this natively over HTTP/2. Next.js 14+ (and now 15.x) wires this up through React's Suspense API and the App Router's loading.tsx convention — no custom infrastructure needed.

In practice, this means your navbar, hero section, and footer render in under 100ms while a database-heavy product grid streams in 800ms later. The user sees *something* immediately. That's the entire game.

Worth noting: streaming isn't magic. It doesn't make your database queries faster. What it does is change when the user perceives slowness — and that gap matters enormously for UX metrics like LCP and INP.

Suspense Boundaries: The Foundation

React Suspense has been around since React 16.6, but it only became genuinely useful for data fetching with the App Router. The core idea is simple: wrap any component that might suspend (i.e., await async data) in a <Suspense> boundary, and React will render the fallback UI until that component resolves.

In the App Router, any async Server Component can suspend. You don't need use() or special hooks — just await inside a component, and React handles the rest:

// app/products/page.tsx
import { Suspense } from 'react'
import { ProductGrid } from './ProductGrid'
import { ProductGridSkeleton } from './ProductGridSkeleton'

export default function ProductsPage() {
  return (
    <main>
      <h1>Our Products</h1>
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
    </main>
  )
}
// app/products/ProductGrid.tsx
async function ProductGrid() {
  // This component suspends. The Suspense boundary above catches it.
  const products = await fetch('/api/products', { cache: 'no-store' }).then(r => r.json())

  return (
    <ul className="grid grid-cols-3 gap-6">
      {products.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

The boundary granularity you choose matters a lot. One giant Suspense wrapper around your whole page body gives you no streaming benefit — you're back to blocking. Honest advice: scope your boundaries to the *smallest data-dependent unit* that makes sense as an independent chunk. A sidebar, a feed, a recommendation panel — these are good candidates. Don't wrap individual list items.

That said, don't go overboard. 12 nested Suspense boundaries creates 12 separate streaming chunks, 12 skeleton states the user sees in sequence, and a visual experience that feels broken. Pick 2–4 meaningful splits per route.

loading.tsx: The File-Based Shortcut

Next.js gives you a zero-boilerplate way to add Suspense to an entire route segment: loading.tsx. Drop it next to your page.tsx and it automatically wraps the page content in a Suspense boundary. Navigation to that route shows the loading UI instantly — even before the page component starts executing.

app/
  dashboard/
    loading.tsx     ← auto Suspense fallback
    page.tsx        ← async Server Component
    layout.tsx
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse space-y-4 p-6">
      <div className="h-8 w-48 rounded bg-zinc-200" />
      <div className="h-48 rounded-xl bg-zinc-200" />
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-32 rounded-lg bg-zinc-200" />
        ))}
      </div>
    </div>
  )
}

One thing that trips people up: loading.tsx wraps the *entire* page.tsx output. So if your page has a fast navbar and a slow data table, the user sees the skeleton for both. For finer control, use explicit <Suspense> boundaries inside page.tsx and skip loading.tsx for that route.

Quick aside: you can use both in the same route. loading.tsx handles the initial navigation flash, while inner <Suspense> boundaries handle streaming within the already-loaded shell. They're not mutually exclusive.

Parallel Data Fetching Inside Suspense

Here's a mistake I see constantly: sequential awaits inside a Server Component. You await one query, then await another, then a third. Your component takes 900ms when it could take 300ms if those fetches ran in parallel. Streaming helps with user perception, but it doesn't fix self-inflicted serial waterfalls.

// BAD — sequential, 900ms total
async function DashboardStats() {
  const users = await getUsers()           // 300ms
  const revenue = await getRevenue()       // 300ms
  const orders = await getOrders()         // 300ms
  // ...
}

// GOOD — parallel, ~300ms total
async function DashboardStats() {
  const [users, revenue, orders] = await Promise.all([
    getUsers(),
    getRevenue(),
    getOrders(),
  ])
  // ...
}

When you combine parallel fetching *with* Suspense boundaries, you get the best of both worlds. Each boundary's async work runs in parallel with other boundaries, and the fastest ones stream to the client first. In Next.js 15, Server Components in different Suspense branches are already parallelized by the React runtime — but within a single component, Promise.all is still on you.

Honestly, most performance issues in Next.js apps I've audited come from sequential data fetching, not from missing streaming. Get your fetches parallel first. Then add streaming to push the perceived-fast envelope.

One more thing — React 19's use() hook lets you pass a Promise from a Server Component to a Client Component, which can then suspend on it. This unlocks patterns like reading a cache-hit immediately without suspending at all, and only streaming when the data actually isn't ready yet.

Partial Prerender (PPR): The Best of Both Worlds

Partial Prerender landed as experimental in Next.js 14.0 and graduated to stable-ish in 15.x. The concept is genuinely clever: statically prerender the *shell* of a page (nav, layout, hero) at build time, then stream in the dynamic parts at request time. You get CDN-speed for the static shell and fresh data for the dynamic pieces — without splitting into two separate routes.

To enable it in Next.js 15, add this to your next.config.ts: ``ts // next.config.ts import type { NextConfig } from 'next' const config: NextConfig = { experimental: { ppr: 'incremental', // or true for all routes }, } export default config ` Then opt specific routes into PPR: `tsx // app/home/page.tsx export const experimental_ppr = true export default function HomePage() { return ( <> <StaticHero /> {/* prerendered at build time */} <Suspense fallback={<FeedSkeleton />}> <LiveFeed /> {/* streamed at request time */} </Suspense> </> ) } ``

The key mental model: everything *outside* Suspense boundaries is treated as static and prerendered. Everything *inside* a Suspense boundary is treated as dynamic and streamed. You're essentially drawing a line between "what I know at build time" and "what I need at request time" using the Suspense boundary itself as the dividing line.

Look, PPR isn't magic either. If your entire page is dynamic (user-specific data everywhere), PPR gives you almost nothing. It shines on marketing pages with personalization widgets, dashboards with a static sidebar and dynamic stats, or e-commerce listing pages where the layout is static but inventory is live. Think about the static-to-dynamic ratio of your actual pages before reaching for it.

Worth noting: as of mid-2026, PPR with ppr: true (all routes) can surface subtle caching bugs — components you thought were dynamic might get accidentally prerendered if they're outside any Suspense boundary. Use 'incremental' and opt in per-route while the API stabilizes.

Error Boundaries and Edge Cases

Streaming makes error handling slightly more complex. With traditional SSR, a data fetch error fails the whole request and you can return a 500 page cleanly. With streaming, the HTTP response has already started — the status code is 200 and the shell is in the browser. You can't change that.

The solution is error.tsx — a file-based error boundary that works parallel to loading.tsx: ``tsx // app/products/error.tsx 'use client' // error boundaries MUST be Client Components export default function ProductsError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div className="rounded-xl border border-red-200 bg-red-50 p-6"> <h2 className="text-red-700 font-semibold">Something went wrong</h2> <p className="mt-1 text-sm text-red-600">{error.message}</p> <button onClick={reset} className="mt-4 rounded-lg bg-red-600 px-4 py-2 text-white text-sm" > Try again </button> </div> ) } ``

This catches errors thrown inside the corresponding page.tsx or nested Server Components within that segment. The fallback renders inline where the content would have been — not as a full-page 500. That's actually better UX: a failed product grid doesn't kill your navbar.

One gotcha: errors thrown during streaming (after the response has started) won't be caught by a global _error.tsx in Next.js. Each segment's error.tsx is the only option. Plan your error boundaries around user-facing impact, not code organization.

Skeleton Design That Actually Works

A bad skeleton UI is worse than a spinner. If your skeleton doesn't match the shape of the real content — wrong number of lines, wrong aspect ratios, wildly different heights — you get a jarring layout shift when the content arrives. That's a 40px+ CLS event and an angry user. Keep the skeleton dimensions within 8px of the actual content layout.

Good skeletons are boring by design. They shouldn't animate dramatically or draw attention. A simple animate-pulse with bg-zinc-200 dark:bg-zinc-700 is almost always the right call. If you're building a premium UI — say, something that would live alongside glassmorphism components or other polished design patterns — you can match the skeleton's border-radius and glass effect to the real card so the transition feels intentional.

// A skeleton that matches a real card
export function CardSkeleton() {
  return (
    <div className="animate-pulse rounded-2xl border border-zinc-100 bg-zinc-50 p-6 shadow-sm">
      <div className="mb-4 h-40 rounded-xl bg-zinc-200" />
      <div className="h-5 w-3/4 rounded bg-zinc-200" />
      <div className="mt-2 h-4 w-1/2 rounded bg-zinc-200" />
    </div>
  )
}

Tools matter here too. If you're generating shadows or gradients for your loading states, the box shadow generator can help you match the skeleton's depth to the real card — so the visual weight stays consistent before and after the stream resolves. Small detail, big impact on perceived quality.

Honestly, I'd rather see a plain gray rectangle that matches the content size than an elaborate skeleton with pulsing gradients that's 50px too tall. Accuracy beats aesthetics in loading states every single time.

FAQ

Does streaming work with static exports (next export)?

No. Streaming requires a Node.js (or Edge) runtime to send chunked HTTP responses. Static exports generate plain HTML files served from a CDN — there's no server to stream from. If you need streaming, you need a server: Vercel, Railway, a Docker container, whatever.

Can I use Suspense boundaries with Client Components?

Yes, but it works differently. Client Components can suspend using the use() hook (React 19+) to unwrap a Promise passed from a Server Component. You can't await directly in a Client Component — use() is the bridge. The Suspense boundary itself can live anywhere in the tree, server or client.

How do I test that my streaming is actually working?

Open Chrome DevTools, go to the Network tab, click your page request, and watch the Waterfall timing. You should see the response transfer spanning multiple seconds with chunks arriving progressively. You can also use curl --no-buffer https://yoursite.com/page and watch HTML arrive in bursts — each Suspense chunk appears as a separate <template> tag injected by React.

Is Partial Prerender production-ready in Next.js 15?

Mostly. It's stable enough for incremental opt-in on specific routes, and Vercel runs it in production on their own properties. For critical paths, test thoroughly — especially around cache invalidation and dynamic data that accidentally falls outside Suspense boundaries. The 'incremental' config option is safer than true for now.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js loading.tsx: Streaming, Suspense and Skeleton Loading StatesReact Suspense Patterns: Loading, Error Boundaries and Nested FallbacksServer-Side Streaming in React + Next.js: Suspense and RSCWeb Font Loading in 2026: next/font, variable fonts and CLS