React Streaming SSR with Next.js: A Practical Tutorial

Build a fast, progressive UI with React streaming SSR in Next.js. Learn Suspense, loading UIs, error boundaries, and streaming APIs with clear examples.

ASOasis
7 min read
React Streaming SSR with Next.js: A Practical Tutorial

Image used for representation purposes only.

Why streaming SSR matters

React’s streaming server‑side rendering (SSR) lets the server flush HTML to the browser in chunks as data becomes ready. Instead of waiting for every database call to finish, users see meaningful UI quickly (skeletons or the page shell), then progressively richer sections arrive and hydrate. The result is better TTFB, improved LCP, and a snappier perceived experience—especially on slow networks or data‑heavy pages.

With the Next.js App Router (React 18+), streaming is the default for server components. You control the experience using Suspense boundaries, route‑segment loading UIs, and error boundaries.

What we’ll build

We’ll create a product page that streams:

  • The product hero renders first.
  • Reviews and recommendations resolve later behind Suspense boundaries.
  • A route‑segment loading.js shows immediate skeletons.
  • We’ll add robust error handling and an optional streamed API route.

Prerequisites

  • Node.js 18 or newer
  • Familiarity with TypeScript/JSX and basic React Suspense concepts
  • Next.js with the App Router enabled (the app/ directory)

Project setup

Initialize a new project:

npx create-next-app@latest next-streaming-ssr
cd next-streaming-ssr
npm run dev

Your structure will look like this:

app/
  layout.tsx
  page.tsx
  product/
    [id]/
      page.tsx
      loading.tsx
      error.tsx
lib/
  data.ts
components/
  Reviews.tsx
  Recommendations.tsx
  Skeletons.tsx

Build a streaming page (server components)

Create data helpers that simulate real APIs and delays.

app/lib is reserved, so create lib/data.ts:

// lib/data.ts
export type Product = { id: string; name: string; price: number; description: string };
export type Review = { id: string; author: string; rating: number; text: string };

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function getProduct(id: string): Promise<Product> {
  // Simulate DB/API latency
  await sleep(300);
  return {
    id,
    name: `UltraDesk ${id}`,
    price: 399,
    description: 'Adjustable standing desk with cable management.'
  };
}

export async function getReviews(id: string): Promise<Review[]> {
  // Slower endpoint to demonstrate streaming
  await sleep(1800);
  return [
    { id: 'r1', author: 'Ava', rating: 5, text: 'Rock solid and quiet.' },
    { id: 'r2', author: 'Noah', rating: 4, text: 'Great value. Easy assembly.' }
  ];
}

export async function getRecommendations(id: string): Promise<Product[]> {
  await sleep(1200);
  return [
    { id: '101', name: 'Cable Tray', price: 29, description: 'Hide wires neatly.' },
    { id: '102', name: 'Anti‑Fatigue Mat', price: 49, description: 'Comfort for long sessions.' }
  ];
}

Page shell that starts streaming immediately

Create app/product/[id]/page.tsx. We’ll start all fetches in parallel, then render the fast parts first and stream the slow parts inside Suspense.

// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { getProduct, getReviews, getRecommendations } from '@/lib/data';
import Reviews from '@/components/Reviews';
import Recommendations from '@/components/Recommendations';
import { ReviewsSkeleton, RecsSkeleton } from '@/components/Skeletons';

// Ensure dynamic rendering (useful when data must bypass static optimization)
export const dynamic = 'force-dynamic';

// This is a Server Component by default
export default async function ProductPage({ params }: { params: { id: string } }) {
  // Kick off all data requests in parallel
  const productPromise = getProduct(params.id);
  const reviewsPromise = getReviews(params.id);
  const recsPromise = getRecommendations(params.id);

  // Wait only for the product shell (fast) to render above the fold
  const product = await productPromise;

  return (
    <main className="mx-auto max-w-3xl p-6 space-y-8">
      <header>
        <h1 className="text-2xl font-semibold">{product.name}</h1>
        <p className="text-gray-600">{product.description}</p>
        <div className="mt-2 text-lg font-medium">${product.price}</div>
      </header>

      {/* Stream reviews when they’re ready */}
      <Suspense fallback={<ReviewsSkeleton />}>
        {/* Pass the promise; child will await it */}
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>

      {/* Stream recommendations separately */}
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations recsPromise={recsPromise} />
      </Suspense>
    </main>
  );
}

Suspense‑aware child components

Each child is a Server Component that awaits its own promise, causing React to stream the fallback first and the final markup later.

// components/Reviews.tsx
import { type Review } from '@/lib/data';

export default async function Reviews({
  reviewsPromise,
}: {
  reviewsPromise: Promise<Review[]>;
}) {
  const reviews = await reviewsPromise; // Suspends until resolved
  return (
    <section>
      <h2 className="text-xl font-semibold mb-2">Reviews</h2>
      <ul className="space-y-3">
        {reviews.map((r) => (
          <li key={r.id} className="rounded border p-3">
            <div className="flex items-center justify-between">
              <span className="font-medium">{r.author}</span>
              <span className="text-amber-600">{'★'.repeat(r.rating)}</span>
            </div>
            <p className="text-gray-700">{r.text}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}
// components/Recommendations.tsx
import { type Product } from '@/lib/data';

export default async function Recommendations({
  recsPromise,
}: {
  recsPromise: Promise<Product[]>;
}) {
  const recs = await recsPromise; // Suspends until resolved
  return (
    <aside>
      <h2 className="text-xl font-semibold mb-2">You might also like</h2>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
        {recs.map((p) => (
          <div key={p.id} className="rounded border p-3">
            <div className="font-medium">{p.name}</div>
            <div className="text-gray-700">{p.description}</div>
            <div className="mt-1">${p.price}</div>
          </div>
        ))}
      </div>
    </aside>
  );
}

Skeleton UIs and route‑segment loading

Use Suspense fallbacks for fine‑grained placeholders and a route‑segment loading.tsx for instant shell feedback while the entire segment is booting.

// components/Skeletons.tsx (Client or Server is fine; no hooks used)
export function ReviewsSkeleton() {
  return (
    <section>
      <h2 className="text-xl font-semibold mb-2">Reviews</h2>
      <div className="animate-pulse space-y-3">
        {[...Array(2)].map((_, i) => (
          <div key={i} className="h-16 rounded bg-gray-200" />
        ))}
      </div>
    </section>
  );
}

export function RecsSkeleton() {
  return (
    <aside>
      <h2 className="text-xl font-semibold mb-2">You might also like</h2>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
        {[...Array(2)].map((_, i) => (
          <div key={i} className="h-20 rounded bg-gray-200 animate-pulse" />
        ))}
      </div>
    </aside>
  );
}
// app/product/[id]/loading.tsx
// This renders instantly while the [id] segment is initializing
export default function Loading() {
  return (
    <main className="mx-auto max-w-3xl p-6 space-y-8">
      <div className="h-8 w-2/3 bg-gray-200 rounded animate-pulse" />
      <div className="space-y-2">
        <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
        <div className="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
      </div>
    </main>
  );
}

Error handling with error.tsx and not-found.tsx

Errors inside a route segment bubble to app/product/[id]/error.tsx. Provide a friendly UI and a reset action.

// app/product/[id]/error.tsx
'use client';

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h2 className="text-xl font-semibold text-red-700">Something went wrong</h2>
      <p className="text-gray-700 mt-2">{error.message}</p>
      <button
        onClick={() => reset()}
        className="mt-4 rounded bg-black px-3 py-2 text-white hover:bg-gray-800"
      >
        Try again
      </button>
    </main>
  );
}

If you need 404 behavior, add app/product/[id]/not-found.tsx and call notFound() from your page or data helpers when the ID is invalid.

// app/product/[id]/not-found.tsx
export default function NotFound() {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h2 className="text-xl font-semibold">Product not found</h2>
      <p className="text-gray-700">Please check the URL and try again.</p>
    </main>
  );
}

Optional: stream from Route Handlers

You can also stream custom responses (e.g., server‑sent updates) from Route Handlers.

// app/api/clock/route.ts
export const runtime = 'nodejs'; // or 'edge' if preferred

export async function GET() {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    start(controller) {
      const iv = setInterval(() => {
        const chunk = `data: ${new Date().toISOString()}\n\n`;
        controller.enqueue(encoder.encode(chunk));
      }, 1000);
      setTimeout(() => {
        clearInterval(iv);
        controller.close();
      }, 5000);
    },
  });

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

Verify streaming locally

  • Browser devtools: open the Network tab for the product route. You should see the document response start rendering before all chunks finish.
  • Curl: watch chunked HTML arrive progressively.
curl -N -i http://localhost:3000/product/42

You’ll observe the head and shell first, then the reviews and recommendations markup.

Performance patterns and pitfalls

  • Start fetches early: kick off parallel work at the top of the server component (const reviewsPromise = getReviews(…)). Await only what’s needed for above‑the‑fold UI.
  • Use multiple Suspense boundaries: group slow, independent sections separately for better interleaving.
  • Prefer server components for data‑heavy UI: less client JS, smaller hydration costs, faster start.
  • Force dynamic only when needed: dynamic = ‘force-dynamic’ or fetch(…, { cache: ’no-store’ }) bypasses static optimization. Use carefully to keep caching opportunities.
  • Keep the root layout fast: avoid slow I/O in app/layout.tsx that would block every page from starting to stream.
  • Handle errors per segment: error.tsx avoids blank screens and lets users retry without losing page state.
  • Beware middleware and proxies: some reverse proxies buffer responses. In traditional Nginx, disable proxy buffering for true streaming.
  • Measure: Lighthouse won’t “see” streaming timing perfectly; combine Real User Monitoring, server logs, and Web Vitals to validate wins.

Troubleshooting

  • “My fallbacks never appear”: Ensure the slow work is inside a Suspense boundary and that you aren’t awaiting everything before rendering. Pass a promise to a child server component and await it there.
  • “The whole page waits”: Check for expensive I/O in layout.tsx or providers. Move slow sections behind Suspense or defer them.
  • “No streaming in production”: Verify your hosting platform supports streaming for SSR responses and that compression/proxy buffering aren’t defeating chunk flushes.
  • “Flaky data during refreshes”: Decide per‑request (no‑store) vs cached (default) fetch behavior. Combine with revalidation where appropriate.

Wrap‑up

Streaming SSR with Next.js turns long, blocking renders into fast, progressive experiences. By kicking off parallel work early, slicing UIs with Suspense, and leaning on route‑segment loading and error boundaries, you’ll deliver first content sooner and maintain responsiveness as data arrives. Start with a shell, stream the rest—your users will feel the difference.

Related Posts