DEV Community

Cover image for Streaming Responses in Next.js App Router — Server-Sent Events and ReadableStream
Aon infotech
Aon infotech

Posted on

Streaming Responses in Next.js App Router — Server-Sent Events and ReadableStream

Long-running operations create a UX problem. A request that takes 10-30 seconds leaves users staring at a spinner with no indication of whether anything is happening. Streaming lets you send partial results as they become available — the user sees progress rather than nothing.

Next.js App Router has solid streaming support through two approaches: React Suspense for component-level streaming, and ReadableStream/Server-Sent Events for API-level streaming. Here's how both work in production, including the pattern I use for long-running generation tasks.


When to Use Each Approach

React Suspense streaming: Best for streaming HTML from Server Components. The page shell renders immediately, then sections fill in as data becomes available. Built into App Router, minimal additional code.

ReadableStream / SSE: Best for streaming data from API routes to client components — progress updates, chunked text generation, status updates for background jobs. More control, more code.


React Suspense Streaming — The Simpler Case

// app/dashboard/page.js
import { Suspense } from 'react';
import { UserStats } from '@/components/UserStats';
import { RecentActivity } from '@/components/RecentActivity';
import { StatsSkeleton, ActivitySkeleton } from '@/components/Skeletons';

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6 p-6">
      {/* Renders immediately */}
      <h1 className="col-span-2 text-2xl font-semibold">Dashboard</h1>

      {/* Streams in when data is ready */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      {/* Streams in independently */}
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

// Server Component — awaits its own data
async function UserStats() {
  const stats = await fetchUserStats(); // Can take 1-3 seconds
  return <StatsDisplay stats={stats} />;
}
Enter fullscreen mode Exit fullscreen mode

Each Suspense boundary streams independently. The page shell (heading) renders immediately. Stats and activity sections each stream in as their data becomes available, without waiting for each other.


ReadableStream API Routes — Streaming Data

For streaming progress updates from an API route:

// app/api/generate/stream/route.ts
import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const jobId = searchParams.get('jobId');

  if (!jobId) {
    return new Response('Missing jobId', { status: 400 });
  }

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      const sendUpdate = (data: object) => {
        const line = `data: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(encoder.encode(line));
      };

      try {
        // Poll until complete or timeout
        const maxAttempts = 60;
        let attempts = 0;

        while (attempts < maxAttempts) {
          const status = await checkJobStatus(jobId);

          sendUpdate({
            status: status.state,
            progress: status.progress,
            message: status.message,
          });

          if (status.state === 'complete') {
            sendUpdate({ status: 'complete', url: status.outputUrl });
            break;
          }

          if (status.state === 'error') {
            sendUpdate({ status: 'error', error: status.error });
            break;
          }

          await sleep(2000); // Poll every 2 seconds
          attempts++;
        }

        if (attempts >= maxAttempts) {
          sendUpdate({ status: 'error', error: 'Timeout' });
        }
      } finally {
        controller.close();
      }
    },
  });

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

function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
Enter fullscreen mode Exit fullscreen mode

Client-Side SSE Consumer

// hooks/useJobStream.ts
'use client';
import { useState, useEffect, useRef } from 'react';

type JobStatus = 'idle' | 'pending' | 'complete' | 'error';

interface JobState {
  status: JobStatus;
  progress: number;
  message: string;
  outputUrl: string | null;
  error: string | null;
}

export function useJobStream(jobId: string | null) {
  const [state, setState] = useState<JobState>({
    status: 'idle',
    progress: 0,
    message: '',
    outputUrl: null,
    error: null,
  });

  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    if (!jobId) return;

    // Close any existing connection
    eventSourceRef.current?.close();

    const eventSource = new EventSource(
      `/api/generate/stream?jobId=${jobId}`
    );

    eventSourceRef.current = eventSource;

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      setState(prev => ({
        ...prev,
        status: data.status ?? prev.status,
        progress: data.progress ?? prev.progress,
        message: data.message ?? prev.message,
        outputUrl: data.url ?? prev.outputUrl,
        error: data.error ?? prev.error,
      }));

      if (data.status === 'complete' || data.status === 'error') {
        eventSource.close();
      }
    };

    eventSource.onerror = () => {
      setState(prev => ({
        ...prev,
        status: 'error',
        error: 'Connection failed',
      }));
      eventSource.close();
    };

    return () => {
      eventSource.close();
    };
  }, [jobId]);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

Usage in a component:

'use client';
import { useJobStream } from '@/hooks/useJobStream';

function GenerationProgress({ jobId }) {
  const { status, progress, message, outputUrl, error } = useJobStream(jobId);

  if (status === 'complete' && outputUrl) {
    return <img src={outputUrl} alt="Generated" className="rounded-xl" />;
  }

  if (status === 'error') {
    return <p className="text-red-500">{error}</p>;
  }

  return (
    <div className="flex flex-col gap-3 p-6">
      <div className="h-2 bg-neutral-200 rounded-full overflow-hidden">
        <div
          className="h-full bg-orange-500 transition-all duration-500"
          style={{ width: `${progress}%` }}
        />
      </div>
      <p className="text-sm text-muted">{message || 'Processing...'}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Streaming Text Generation

For streaming text responses (like AI-generated text):

// app/api/describe/route.ts
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { prompt } = await request.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      // Simulate streaming text chunks
      // In production, this would come from an AI API that supports streaming
      const words = generateDescription(prompt).split(' ');

      for (const word of words) {
        controller.enqueue(encoder.encode(word + ' '));
        await sleep(50); // Simulate streaming delay
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
}
Enter fullscreen mode Exit fullscreen mode

Connection Management Edge Cases

Handle browser tab visibility. EventSource reconnects automatically on network interruption, but connections left open in background tabs waste server resources.

useEffect(() => {
  const handleVisibilityChange = () => {
    if (document.hidden) {
      eventSourceRef.current?.close();
    } else if (jobId && state.status === 'pending') {
      // Reconnect when tab becomes visible again
      reconnect();
    }
  };

  document.addEventListener('visibilitychange', handleVisibilityChange);
  return () => {
    document.removeEventListener('visibilitychange', handleVisibilityChange);
  };
}, [jobId, state.status]);
Enter fullscreen mode Exit fullscreen mode

Set server-side timeouts. Long-running streams should have a maximum duration to prevent resource leaks.

Handle the Vercel timeout. Vercel's function timeout is 60 seconds by default (10 seconds on Hobby). For longer operations, use edge functions (which support streaming better) or ensure your polling approach completes within the timeout window.


Testing Streaming Endpoints

Standard fetch-based tests don't work well for streaming. A few approaches:

// Integration test approach
test('stream sends progress updates then complete', async () => {
  const jobId = await createTestJob();
  const updates: any[] = [];

  await new Promise<void>((resolve, reject) => {
    const es = new EventSource(`/api/generate/stream?jobId=${jobId}`);

    es.onmessage = (event) => {
      const data = JSON.parse(event.data);
      updates.push(data);

      if (data.status === 'complete' || data.status === 'error') {
        es.close();
        resolve();
      }
    };

    es.onerror = () => {
      es.close();
      reject(new Error('Stream error'));
    };

    // Timeout
    setTimeout(() => {
      es.close();
      reject(new Error('Stream timeout'));
    }, 30000);
  });

  // Verify updates sequence
  const statuses = updates.map(u => u.status);
  expect(statuses).toContain('pending');
  expect(statuses[statuses.length - 1]).toBe('complete');
});
Enter fullscreen mode Exit fullscreen mode

Manual testing: Browser DevTools → Network tab → filter by "EventStream" type → click a stream request → see the "EventStream" sub-tab showing each message as it arrives. The most useful debugging tool for SSE in development.


Summary

React Suspense streaming handles the common case of streaming HTML from Server Components with minimal code — wrap async components in Suspense boundaries and Next.js handles the rest.

ReadableStream + SSE handles the more complex case of streaming progress updates or text from API routes to client components. The pattern: create a ReadableStream in the route handler, write SSE-formatted events, return with the appropriate headers. Consume with EventSource on the client, update state as events arrive.

The connection management details — cleanup on unmount, tab visibility handling, server-side timeouts — are what separate a production-ready implementation from a demo.

Top comments (0)