Skip to main content
Back to Blog
Tutorials
3 min read
January 20, 2025

How to Implement Server-Sent Events Properly in Next.js

Use server-sent events for efficient one-way streaming with reconnection, event types, message IDs, and connection management.

Ryel Banfield

Founder & Lead Developer

SSE is simpler than WebSockets for one-way data streaming. Here is how to use it correctly.

SSE Route Handler

// app/api/events/route.ts
import { NextRequest } from "next/server";

// Connection registry
const connections = new Map<string, ReadableStreamDefaultController>();

export async function GET(request: NextRequest) {
  const userId = request.nextUrl.searchParams.get("userId");
  const lastEventId = request.headers.get("Last-Event-ID");

  if (!userId) {
    return new Response("userId required", { status: 400 });
  }

  const stream = new ReadableStream({
    start(controller) {
      // Register connection
      connections.set(userId, controller);

      // Send initial connection event
      const event = formatSSE({
        event: "connected",
        data: { userId, timestamp: Date.now() },
        id: crypto.randomUUID(),
      });
      controller.enqueue(new TextEncoder().encode(event));

      // If client reconnected, send missed events
      if (lastEventId) {
        sendMissedEvents(controller, userId, lastEventId);
      }

      // Keep-alive heartbeat every 30 seconds
      const heartbeat = setInterval(() => {
        try {
          controller.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
        } catch {
          clearInterval(heartbeat);
        }
      }, 30000);

      // Clean up on disconnect
      request.signal.addEventListener("abort", () => {
        clearInterval(heartbeat);
        connections.delete(userId);
        try {
          controller.close();
        } catch {
          // Already closed
        }
      });
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // Disable Nginx buffering
    },
  });
}

interface SSEMessage {
  event?: string;
  data: unknown;
  id?: string;
  retry?: number;
}

function formatSSE(message: SSEMessage): string {
  let result = "";
  if (message.id) result += `id: ${message.id}\n`;
  if (message.event) result += `event: ${message.event}\n`;
  if (message.retry) result += `retry: ${message.retry}\n`;
  result += `data: ${JSON.stringify(message.data)}\n\n`;
  return result;
}

// Send an event to a specific user
export function sendToUser(userId: string, event: SSEMessage) {
  const controller = connections.get(userId);
  if (!controller) return false;

  try {
    const formatted = formatSSE(event);
    controller.enqueue(new TextEncoder().encode(formatted));
    return true;
  } catch {
    connections.delete(userId);
    return false;
  }
}

// Broadcast to all connections
export function broadcast(event: SSEMessage) {
  const formatted = formatSSE(event);
  const encoded = new TextEncoder().encode(formatted);

  for (const [userId, controller] of connections) {
    try {
      controller.enqueue(encoded);
    } catch {
      connections.delete(userId);
    }
  }
}

async function sendMissedEvents(
  controller: ReadableStreamDefaultController,
  userId: string,
  lastEventId: string,
) {
  // In production, query your database for events after lastEventId
  // This is a placeholder for the recovery mechanism
}

Client-Side Hook

"use client";

import { useEffect, useRef, useCallback, useState } from "react";

interface UseSSEOptions {
  url: string;
  onMessage?: (data: unknown) => void;
  onError?: (error: Event) => void;
  events?: Record<string, (data: unknown) => void>;
  enabled?: boolean;
}

export function useSSE({
  url,
  onMessage,
  onError,
  events = {},
  enabled = true,
}: UseSSEOptions) {
  const [connected, setConnected] = useState(false);
  const [reconnecting, setReconnecting] = useState(false);
  const sourceRef = useRef<EventSource | null>(null);
  const retriesRef = useRef(0);

  const connect = useCallback(() => {
    if (sourceRef.current) {
      sourceRef.current.close();
    }

    const source = new EventSource(url);
    sourceRef.current = source;

    source.onopen = () => {
      setConnected(true);
      setReconnecting(false);
      retriesRef.current = 0;
    };

    source.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        onMessage?.(data);
      } catch {
        onMessage?.(event.data);
      }
    };

    source.onerror = (event) => {
      setConnected(false);

      if (source.readyState === EventSource.CLOSED) {
        // Connection closed, EventSource will not auto-reconnect
        retriesRef.current++;
        const delay = Math.min(1000 * 2 ** retriesRef.current, 30000);
        setReconnecting(true);

        setTimeout(() => {
          if (enabled) connect();
        }, delay);
      }

      onError?.(event);
    };

    // Register named event handlers
    for (const [eventName, handler] of Object.entries(events)) {
      source.addEventListener(eventName, (event) => {
        try {
          const data = JSON.parse((event as MessageEvent).data);
          handler(data);
        } catch {
          handler((event as MessageEvent).data);
        }
      });
    }
  }, [url, onMessage, onError, events, enabled]);

  useEffect(() => {
    if (!enabled) return;
    connect();
    return () => {
      sourceRef.current?.close();
      sourceRef.current = null;
    };
  }, [connect, enabled]);

  const disconnect = useCallback(() => {
    sourceRef.current?.close();
    sourceRef.current = null;
    setConnected(false);
  }, []);

  return { connected, reconnecting, disconnect };
}

Usage Examples

"use client";

import { useState, useCallback } from "react";
import { useSSE } from "@/hooks/use-sse";

interface Notification {
  id: string;
  title: string;
  body: string;
  type: "info" | "success" | "warning";
}

export function LiveNotifications({ userId }: { userId: string }) {
  const [notifications, setNotifications] = useState<Notification[]>([]);

  const { connected, reconnecting } = useSSE({
    url: `/api/events?userId=${userId}`,
    events: {
      notification: useCallback((data: unknown) => {
        setNotifications((prev) => [data as Notification, ...prev].slice(0, 50));
      }, []),
      connected: useCallback((data: unknown) => {
        console.log("SSE connected:", data);
      }, []),
    },
  });

  return (
    <div className="space-y-2">
      <div className="flex items-center gap-2 text-sm">
        <span
          className={`w-2 h-2 rounded-full ${
            connected
              ? "bg-green-500"
              : reconnecting
                ? "bg-amber-500 animate-pulse"
                : "bg-red-500"
          }`}
        />
        <span className="text-muted-foreground">
          {connected
            ? "Connected"
            : reconnecting
              ? "Reconnecting..."
              : "Disconnected"}
        </span>
      </div>

      {notifications.map((n) => (
        <div
          key={n.id}
          className={`p-3 rounded-lg text-sm border ${
            n.type === "success"
              ? "bg-green-50 border-green-200"
              : n.type === "warning"
                ? "bg-amber-50 border-amber-200"
                : "bg-blue-50 border-blue-200"
          }`}
        >
          <p className="font-medium">{n.title}</p>
          <p className="text-muted-foreground">{n.body}</p>
        </div>
      ))}
    </div>
  );
}

Trigger Events From Server Actions

// app/actions.ts
"use server";

import { sendToUser, broadcast } from "@/app/api/events/route";

export async function notifyUser(userId: string, message: string) {
  sendToUser(userId, {
    event: "notification",
    data: {
      id: crypto.randomUUID(),
      title: "New notification",
      body: message,
      type: "info",
    },
    id: crypto.randomUUID(),
  });
}

export async function broadcastUpdate(data: unknown) {
  broadcast({
    event: "update",
    data,
    id: crypto.randomUUID(),
  });
}

Need Real-Time Updates?

We build efficient streaming architectures for live data. Contact us to discuss your project.

SSEserver-sent eventsstreamingreal-timeNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles