Skip to content
Codeloom
Next.js

Streaming SSR and Partial Prerendering in Next.js

Learn how Next.js streaming SSR and partial prerendering work together to deliver fast initial loads with dynamic content that streams in progressively.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How streaming SSR sends HTML progressively to the browser
  • Using Suspense boundaries to control what streams and when
  • What partial prerendering (PPR) is and how it differs from ISR
  • Combining static shells with dynamic holes for optimal performance
  • Practical patterns for dashboards, e-commerce, and content sites

Prerequisites

  • Understanding of [Next.js App Router](/blog/nextjs-app-router-basics)
  • Familiarity with [React Suspense](/blog/react-suspense-and-data-fetching)
  • Basic knowledge of [server components](/blog/nextjs-server-components)

Traditional server-side rendering generates the entire HTML document on the server and sends it all at once. The browser cannot display anything until the last database query finishes and the last component renders. Streaming SSR changes this by sending HTML in chunks as each piece becomes ready, letting the browser start rendering immediately.

How streaming SSR works

When you use React Suspense in a server component, Next.js splits the HTML response at each Suspense boundary. The outer shell and any resolved components are sent first. When a suspended component finishes, its HTML is streamed to the browser and injected into the correct position via an inline <script>.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardHeader } from '@/components/DashboardHeader';
import { RevenueChart } from '@/components/RevenueChart';
import { RecentOrders } from '@/components/RecentOrders';
import { UserActivity } from '@/components/UserActivity';

export default function DashboardPage() {
  return (
    <div>
      <DashboardHeader />

      <div className="grid grid-cols-2 gap-4 mt-6">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>

      <Suspense fallback={<ActivitySkeleton />}>
        <UserActivity />
      </Suspense>
    </div>
  );
}

function ChartSkeleton() {
  return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}

function OrdersSkeleton() {
  return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}

function ActivitySkeleton() {
  return <div className="h-48 bg-gray-100 rounded animate-pulse" />;
}

The browser receives this sequence:

  1. The <DashboardHeader /> and skeleton placeholders arrive immediately
  2. When RevenueChart finishes its database query, its HTML streams in and replaces <ChartSkeleton />
  3. RecentOrders and UserActivity follow as they resolve, independently

Each component becomes interactive as it arrives. The user sees a progressively building page instead of staring at a blank screen.

Choosing Suspense boundary placement

Where you place Suspense boundaries determines what the user sees first. The goal is to show meaningful content as early as possible:

// Good: Each slow section gets its own boundary
<Suspense fallback={<Skeleton />}>
  <SlowComponent />
</Suspense>
<Suspense fallback={<Skeleton />}>
  <AnotherSlowComponent />
</Suspense>

// Bad: One big boundary wraps everything
<Suspense fallback={<FullPageSkeleton />}>
  <SlowComponent />
  <AnotherSlowComponent />
  <FastComponent />  {/* This gets blocked too */}
</Suspense>

Rules of thumb:

  • Wrap each independent data-fetching section in its own Suspense boundary
  • Do not wrap fast components that render instantly unless they depend on a slow parent
  • Place boundaries at visual section breaks so the skeleton feels natural
  • Nest boundaries when a section has sub-sections with different loading speeds

loading.tsx as an automatic Suspense boundary

The loading.tsx file in any route segment creates an automatic Suspense boundary around the page:

app/
  dashboard/
    loading.tsx    ← wraps page.tsx in Suspense
    page.tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
      <div className="h-64 bg-gray-200 rounded animate-pulse" />
    </div>
  );
}

This is convenient but coarse-grained. For dashboards with multiple independent sections, explicit Suspense boundaries inside the page give you finer control.

Partial prerendering (PPR)

Partial prerendering combines static and dynamic rendering in a single page. The static parts are prerendered at build time and served from the CDN. The dynamic parts are left as “holes” that get filled in via streaming at request time.

Enable PPR in your Next.js config:

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

const config: NextConfig = {
  experimental: {
    ppr: true,
  },
};

export default config;

With PPR enabled, Next.js analyzes your page at build time:

  • Components outside Suspense boundaries that do not use dynamic APIs (cookies(), headers(), searchParams) are statically prerendered
  • Components inside Suspense boundaries that use dynamic APIs are left as dynamic holes
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { PersonalizedRecommendations } from '@/components/Recommendations';
import { AddToCartButton } from '@/components/AddToCartButton';

export default function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <div>
      {/* Static: prerendered at build time */}
      <ProductDetails params={params} />

      {/* Dynamic: streams in at request time */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <PersonalizedRecommendations />
      </Suspense>

      {/* Dynamic: depends on user session */}
      <Suspense fallback={<ButtonSkeleton />}>
        <AddToCartButton />
      </Suspense>
    </div>
  );
}

The static HTML shell for the product details is served instantly from the CDN. The personalized recommendations and cart button stream in from the server. The result is a sub-100ms Time to First Byte for the static shell with dynamic content filling in shortly after.

PPR vs. ISR vs. SSR

FeatureSSRISRPPR
Static shell from CDNNoYes (whole page)Yes (per component)
Dynamic contentEntire pageRevalidation intervalPer Suspense boundary
Time to First ByteSlow (blocked by data)Fast (cached page)Fast (static shell)
PersonalizationFullNone until revalidationYes, in dynamic holes

PPR gives you the best of both worlds: CDN-speed initial loads with per-request personalization where you need it.

Streaming with route handlers

You can also stream responses from route handlers for custom use cases:

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        const chunk = encoder.encode(`data: chunk ${i}\n\n`);
        controller.enqueue(chunk);
        await new Promise((r) => setTimeout(r, 500));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

Debugging streaming behavior

To see what is streaming and when, use the browser’s Network tab:

  1. Open DevTools and go to the Network tab
  2. Navigate to your page
  3. Click on the document request
  4. Switch to the “Response” tab and enable “Streaming”
  5. Watch chunks arrive in real time

You can also add timing logs in your server components:

async function RevenueChart() {
  const start = Date.now();
  const data = await fetchRevenueData();
  console.log(`RevenueChart resolved in ${Date.now() - start}ms`);

  return <Chart data={data} />;
}

Common pitfalls

Waterfall data fetching inside Suspense. If component A inside a Suspense boundary fetches data and then renders component B which also fetches data, you get a waterfall. Fetch data in parallel at the page level and pass it down.

Too many Suspense boundaries. Each boundary adds a skeleton flash. If three sections resolve within 50ms of each other, the user sees three separate skeleton-to-content transitions. Group closely-timed components under one boundary.

Dynamic APIs leak outside Suspense. If you call cookies() outside a Suspense boundary with PPR enabled, the entire page becomes dynamic, and you lose the static shell benefit. Keep dynamic calls inside Suspense boundaries.

Streaming and partial prerendering represent the current state of the art for Next.js performance. By separating your pages into static shells and dynamic holes, you get instant loads from the CDN while still serving personalized, real-time content.