DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Next.js SSE: Reliable Server‑Sent Events (App Router)

Why SSE in Next.js? Why boundaries matter

Most teams start fixing slow queries and sprinkling caches. Senior engineers look at boundaries: separate streaming concerns from domain logic, avoid tight coupling between UI hooks and persistence, and ensure your streaming endpoints are small, composable adapters.

Server‑Sent Events (SSE) are a simple, reliable way to stream one‑way updates (server → client) without WebSocket complexity. In the App Router you implement SSE with the Web Streams API: return a Response whose body is a ReadableStream and set the correct headers so intermediaries and CDNs don’t buffer the stream.

This article shows a production‑safe pattern for next.js server-sent events using the App Router, including ReadableStream usage, heartbeats, Last‑Event‑ID replay, auth, and separation of concerns so you can refactor boundaries instead of endlessly optimizing SQL.

Route Handler: a minimal, resilient SSE endpoint

Key rules:

  • Return the Response immediately with a ReadableStream. Put async loops inside start(controller) so Next.js doesn’t buffer the result.
  • Add export const dynamic = "force-dynamic" to disable caching.
  • Use request.signal to clean up when the client disconnects.
  • Set these headers to avoid buffering: Content-Type, Cache-Control, Connection, X-Accel-Buffering.

Example: app/api/metrics/stream/route.ts

export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // or 'edge' where appropriate

export async function GET(request: Request) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // Send suggested client retry interval
      controller.enqueue(encoder.encode("retry: 5000

"));

      // Send an initial snapshot (adapter boundary: fetch state via an adapter)
      const snapshot = await metricsAdapter.getSnapshot();
      controller.enqueue(encoder.encode(`event: snapshot
data: ${JSON.stringify(snapshot)}

`));

      // Subscribe to live updates from an adapter (e.g., Redis pub/sub)
      const onUpdate = (event: unknown) => {
        controller.enqueue(encoder.encode(`event: metrics
data: ${JSON.stringify(event)}

`));
      };
      const subscriber = metricsAdapter.subscribe(onUpdate);

      // Heartbeat to keep proxies from closing idle connections
      const heartbeat = setInterval(() => {
        try { controller.enqueue(encoder.encode(': heartbeat

')); } catch { clearInterval(heartbeat); }
      }, 20_000);

      // Cleanup on disconnect
      request.signal.addEventListener('abort', () => {
        clearInterval(heartbeat);
        subscriber.unsubscribe();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      'X-Accel-Buffering': 'no',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Notes on runtime and caching

  • Use runtime = 'edge' to avoid serverless timeouts on some platforms; nodejs can be fine with longer limits. Check your host.
  • Always add dynamic = 'force-dynamic' — SSE must never be cached.

Client: EventSource and a reusable hook

The browser's native EventSource handles automatic reconnects and respects id/Last-Event-ID. Keep the client code in a "use client" component and keep state bounded to avoid memory growth in long‑lived sessions.

Simple reusable hook:

'use client';
import { useEffect, useRef, useState } from 'react';

export function useSSE<T = unknown>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [connected, setConnected] = useState(false);
  const esRef = useRef<EventSource | null>(null);

  useEffect(() => {
    const es = new EventSource(url);
    esRef.current = es;

    es.onopen = () => setConnected(true);
    es.onmessage = (e) => setData(JSON.parse(e.data));
    es.onerror = () => setConnected(false);

    return () => { es.close(); esRef.current = null; };
  }, [url]);

  return { data, connected };
}
Enter fullscreen mode Exit fullscreen mode

For POST‑based streaming (e.g., streaming an AI completion) EventSource won't work — use fetch + readable stream reader instead and parse SSE frames manually.

Replay, Last‑Event‑ID and durable storage

An important boundary: your SSE endpoint should be an adapter between clients and durable event storage (Redis Streams, Postgres pub/sub, etc.). Implement replay using Last‑Event‑ID so clients reconnect without missing events.

Pattern:

  • On connect, read request.headers.get('last-event-id').
  • If present, query your durable store for events after that ID and replay them.
  • If the store no longer contains those events, send a fresh snapshot event instead.

This keeps the streaming adapter stateless while the durable store owns ordering and retention.

Production hygiene: heartbeats, headers, and caps

  • Heartbeat (comment lines starting with :) every 15–30s prevents load balancers and proxies from closing idle connections.
  • retry: <ms> tells the browser how long to wait before reconnecting.
  • Send id: with events so the browser will set Last‑Event‑ID on reconnect and you can resume.
  • Cap client-side arrays (e.g., last 100 events) to prevent unbounded memory growth in dashboards.
  • Avoid server compression on SSE endpoints; proxies may buffer compressed chunks. Use no-transform and X-Accel-Buffering: no for nginx.

Auth and security

Authenticate once at connect time inside the route handler. EventSource supports cookies and CORS credentials for GET; for token auth prefer short‑lived query tokens or cookie‑based session auth. Validate authorization inside the route handler before subscribing to the feed — never trust client params alone.

Separating boundaries: adapter pattern (why this matters)

Returning to the opening insight: the fix is often a boundary, not a faster SQL query. Put streaming logic in a small adapter layer:

  • metricsAdapter.getSnapshot(), metricsAdapter.subscribe(), persistEvent().

This keeps your React components and data-fetching hooks free from persistence concerns. You can change the backing store (Redis → Postgres → custom stream) without touching client code. When you decouple, bundle size, re-renders, and unexpected cache invalidation problems fall away.

Conclusion

Next.js makes SSE practical via the App Router and ReadableStream, but production correctness depends on architectural boundaries: adapters for persistence, clear replay semantics, and robust route handler patterns (headers, heartbeats, abort cleanup). Refactor boundaries first — then optimize implementations.

If you try this pattern, collect traces around subscribe/unsubscribe and memory usage. And if you've recently refactored a boundary (UI ↔ persistence), I'd love to hear what changed and the performance wins you saw.

Top comments (0)