0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 강의

경로 처리기에서 보내는 서버 이벤트

경로 처리기에서 SSE 스트림과 재연결 로직으로 클라이언트에 실시간 업데이트를 전송하는 방법을 배웁니다.

경로 처리기에서 보내는 서버 이벤트은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Next.js 15 Fullstack (App Router + Server Actions) 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

What Are Server-Sent Events?

Server-Sent Events (SSE) is a browser-native protocol that lets the server push data to the client over a single, long-lived HTTP connection. Unlike WebSockets, SSE is unidirectional — the server writes, the client reads.

  • Built on plain HTTP/1.1 or HTTP/2
  • The browser's EventSource API handles connection and automatic reconnection
  • Each message is a UTF-8 text frame with a defined wire format
  • Works through standard firewalls and proxies that block WebSockets

SSE is ideal for dashboards, live feeds, progress bars, and any scenario where only the server needs to initiate updates.

SSE Wire Format

The SSE protocol uses the text/event-stream MIME type. Each event is a block of plain text lines followed by a blank line:

  • data: <payload> — the actual message body (required)
  • event: <name> — optional custom event type (client listens with addEventListener)
  • id: <value> — optional cursor the browser sends back as Last-Event-ID on reconnect
  • retry: <ms> — tells the browser how long to wait before reconnecting

A minimal event looks like this:

// Raw SSE frame sent over the wire (TypeScript string)
const frame =
  'id: 42\n' +
  'event: stock-update\n' +
  'data: {"symbol":"AAPL","price":189.50}\n' +
  'retry: 3000\n' +
  '\n'; // <-- blank line terminates the event

console.log(frame);

Creating a Route Handler for SSE

In Next.js 15 (App Router) you create an SSE endpoint as a plain Route Handler inside app/api/. The key requirements are:

  • Return a Response with Content-Type: text/event-stream
  • Set Cache-Control: no-cache and Connection: keep-alive so proxies do not buffer the stream
  • Pass a ReadableStream as the response body so Node.js keeps the connection open

The ReadableStream constructor accepts a start callback that receives a controller — call controller.enqueue() to push chunks and controller.close() to terminate.

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

export const dynamic = 'force-dynamic'; // never cache this route

export function GET(_req: NextRequest): Response {
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      // Send one event immediately
      controller.enqueue(
        encoder.encode('data: {"message":"connected"}\n\n')
      );

      // Close after the first message (demo only)
      controller.close();
    },
  });

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

Emitting Periodic Events with setInterval

Most real SSE endpoints push events on a schedule or in response to data changes. Use setInterval inside the start callback to push ticks, and clean up with the cancel hook when the client disconnects.

Always clear the interval inside cancel — without this, the timer keeps firing and leaks memory even after the browser closes the tab.

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

export const dynamic = 'force-dynamic';

export function GET(_req: NextRequest): Response {
  const encoder = new TextEncoder();
  let intervalId: ReturnType<typeof setInterval>;
  let counter = 0;

  const stream = new ReadableStream({
    start(controller) {
      intervalId = setInterval(() => {
        const payload = JSON.stringify({ tick: ++counter, ts: Date.now() });
        controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
      }, 1000);
    },
    cancel() {
      // Called when the client closes the connection
      clearInterval(intervalId);
      console.log('SSE client disconnected — interval cleared');
    },
  });

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

Sending Named Events and IDs

Using named events and event IDs gives you finer control on the client. Named events let different parts of the UI subscribe to specific event types, while IDs enable resumable streams — the browser sends the last received ID as Last-Event-ID header on reconnect so the server can replay missed events.

  • Frame format: id: N\nevent: name\ndata: ...\n\n
  • Client listens with source.addEventListener('name', handler)
  • Read request.headers.get('last-event-id') in the Route Handler to resume
// app/api/events/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

function encodeEvent(id: number, event: string, data: unknown): Uint8Array {
  const encoder = new TextEncoder();
  const frame =
    `id: ${id}\n` +
    `event: ${event}\n` +
    `data: ${JSON.stringify(data)}\n\n`;
  return encoder.encode(frame);
}

export function GET(req: NextRequest): Response {
  const lastId = Number(req.headers.get('last-event-id') ?? '0');
  let id = lastId;
  let intervalId: ReturnType<typeof setInterval>;

  const stream = new ReadableStream({
    start(controller) {
      intervalId = setInterval(() => {
        id++;
        controller.enqueue(
          encodeEvent(id, 'notification', { message: `Event ${id}` })
        );
      }, 2000);
    },
    cancel() {
      clearInterval(intervalId);
    },
  });

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

Consuming SSE with EventSource on the Client

The browser's built-in EventSource API connects to an SSE endpoint and automatically reconnects if the connection drops. Use it inside a Client Component with useEffect to subscribe when the component mounts and unsubscribe when it unmounts.

  • new EventSource('/api/sse') — opens the connection
  • source.onmessage — receives unnamed (data: only) events
  • source.addEventListener('name', fn) — receives named events
  • source.close() — closes the connection and stops reconnect attempts
'use client';
import { useEffect, useState } from 'react';

type Tick = { tick: number; ts: number };

export default function LiveTicker() {
  const [latest, setLatest] = useState<Tick | null>(null);

  useEffect(() => {
    const source = new EventSource('/api/ticker');

    source.onmessage = (e: MessageEvent<string>) => {
      setLatest(JSON.parse(e.data) as Tick);
    };

    source.onerror = () => {
      // EventSource will reconnect automatically after 3 s (default)
      console.warn('SSE error — browser will retry');
    };

    return () => source.close(); // cleanup on unmount
  }, []);

  if (!latest) return <p>Waiting for first tick…</p>;
  return (
    <p>
      Tick <strong>{latest.tick}</strong> received at{' '}
      {new Date(latest.ts).toLocaleTimeString()}
    </p>
  );
}

Reading Last-Event-ID for Resumable Streams

When the EventSource reconnects it automatically attaches the Last-Event-ID header. The server can read this value and replay any events the client missed — making the stream resumable without extra client code.

A typical pattern stores recent events in a short in-memory ring buffer (or a Redis list in production) keyed by their ID, then replays all events with id > lastId before resuming live emission.

// Simplified in-memory event log (single-instance demo)
const recentEvents: Array<{ id: number; data: string }> = [];
let globalId = 0;

export function recordEvent(data: string) {
  globalId++;
  recentEvents.push({ id: globalId, data });
  if (recentEvents.length > 100) recentEvents.shift(); // ring buffer
}

export function getEventsSince(lastId: number) {
  return recentEvents.filter((e) => e.id > lastId);
}

// In the Route Handler:
// const missed = getEventsSince(Number(req.headers.get('last-event-id') ?? '0'));
// for (const e of missed) controller.enqueue(encodeEvent(e.id, 'update', e.data));

Handling AbortSignal for Clean Shutdown

Next.js 15 exposes request.signal (an AbortSignal) that fires when the client navigates away or closes the tab. Listening to it is more reliable than the ReadableStream cancel() hook in environments that run on Node.js HTTP/2 or edge runtimes.

  • req.signal.addEventListener('abort', cleanup)
  • Combine with the cancel hook for belt-and-suspenders cleanup
  • Always guard controller.enqueue after abort to avoid WritableStream closed errors
// app/api/live/route.ts
import { NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export function GET(req: NextRequest): Response {
  const encoder = new TextEncoder();
  let closed = false;
  let intervalId: ReturnType<typeof setInterval>;

  const stream = new ReadableStream({
    start(controller) {
      req.signal.addEventListener('abort', () => {
        closed = true;
        clearInterval(intervalId);
        controller.close();
      });

      intervalId = setInterval(() => {
        if (closed) return;
        const data = JSON.stringify({ time: new Date().toISOString() });
        controller.enqueue(encoder.encode(`data: ${data}\n\n`));
      }, 1000);
    },
    cancel() {
      closed = true;
      clearInterval(intervalId);
    },
  });

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

Broadcasting to Multiple Clients

A single Route Handler instance handles one client. To fan-out one event to all connected clients you need a shared publish-subscribe channel.

  • In-process: an EventEmitter singleton works for single-instance deployments (e.g. a single Vercel container or self-hosted Node.js server)
  • Multi-instance: use Redis Pub/Sub, Upstash, or a message queue so all replicas receive the event

Each SSE Route Handler subscribes to the shared emitter on connect and unsubscribes on disconnect to avoid memory leaks.

// lib/sse-bus.ts — singleton EventEmitter (single-instance only)
import { EventEmitter } from 'events';

const bus = new EventEmitter();
bus.setMaxListeners(500); // raise limit for many concurrent clients

export default bus;

// --- app/api/updates/route.ts ---
// import bus from '@/lib/sse-bus';
// import { NextRequest } from 'next/server';
//
// export function GET(req: NextRequest): Response {
//   const encoder = new TextEncoder();
//   let closed = false;
//
//   const stream = new ReadableStream({
//     start(controller) {
//       const handler = (payload: unknown) => {
//         if (closed) return;
//         controller.enqueue(
//           encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
//         );
//       };
//       bus.on('update', handler);
//       req.signal.addEventListener('abort', () => {
//         closed = true;
//         bus.off('update', handler);
//         controller.close();
//       });
//     },
//   });
//
//   return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } });
// }

Reconnect Logic and Retry Hints

The browser retries a dropped SSE connection automatically, but you can tune the behavior:

  • Send retry: 5000\n\n (ms) at connection start to tell the browser to wait 5 seconds before reconnecting
  • On reconnect, read Last-Event-ID and replay missed events
  • If you want to stop reconnection (e.g. the session expired), close the connection with HTTP 204 No Content — EventSource will not retry a 204 response

For authentication, pass credentials as a query parameter or cookie — EventSource does not support custom request headers.

// Helper that formats a full SSE preamble with retry hint
function sseHeaders(): HeadersInit {
  return {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
  };
}

function retryFrame(ms: number): string {
  return `retry: ${ms}\n\n`;
}

// Unauthorized? Close with 204 to suppress browser retry loop
function unauthorizedSSE(): Response {
  return new Response(null, { status: 204 });
}

// Usage in route:
// const token = req.nextUrl.searchParams.get('token');
// if (!isValid(token)) return unauthorizedSSE();
// controller.enqueue(encoder.encode(retryFrame(5000)));

Testing SSE Endpoints with curl

Before wiring up a client component, verify the SSE stream directly in the terminal with curl. This lets you confirm headers, frame format, and event cadence without a browser.

  • curl -N disables buffering so frames appear as they arrive
  • -H 'Accept: text/event-stream' mimics what EventSource sends
  • Watch for the blank line between events — a missing blank line means events will not be parsed by the browser
  • Press Ctrl-C to disconnect and confirm the server logs the cancel / abort
// Run your Next.js dev server, then in a second terminal:
// curl -N -H 'Accept: text/event-stream' http://localhost:3000/api/ticker
//
// Expected output (one block per second):
// data: {"tick":1,"ts":1718000001000}
//
// data: {"tick":2,"ts":1718000002000}
//
// Replay from a specific event ID:
// curl -N -H 'Last-Event-ID: 10' http://localhost:3000/api/events

// TypeScript utility — build SSE test frames in unit tests
function parseSSEFrame(raw: string): Record<string, string> {
  const result: Record<string, string> = {};
  for (const line of raw.split('\n')) {
    const colon = line.indexOf(':');
    if (colon === -1) continue;
    const key = line.slice(0, colon).trim();
    const value = line.slice(colon + 1).trim();
    result[key] = value;
  }
  return result;
}

console.log(parseSSEFrame('id: 5\nevent: tick\ndata: {"n":5}\n'));

Knowledge Check: SSE Reconnection Suppression

Consider the following scenario: a user's session token has expired and a new SSE connection attempt reaches your Route Handler. You want the browser to stop retrying automatically.

Which HTTP response should the server return to suppress the EventSource automatic reconnect loop?

Lesson Recap: SSE from Route Handlers

In this lesson you built a complete Server-Sent Events pipeline in Next.js 15:

  • Wire format: text/event-stream frames with data:, event:, id:, and retry: fields separated by blank lines
  • Route Handler: return a ReadableStream with Content-Type: text/event-stream and Cache-Control: no-cache headers; export dynamic = 'force-dynamic'
  • Cleanup: use both ReadableStream cancel() and req.signal abort listener to clear intervals and avoid memory leaks
  • Resumability: assign incremental IDs, read Last-Event-ID on reconnect, and replay missed events from a ring buffer
  • Client: EventSource in a Client Component useEffect; call source.close() on unmount
  • Fan-out: share an EventEmitter singleton (single-instance) or Redis Pub/Sub (multi-instance) across Route Handler invocations
  • Auth: pass tokens via query params or cookies; return 204 to stop the reconnect loop on expired sessions

SSE is a lightweight, HTTP-native alternative to WebSockets for server-to-client streaming — perfect for live dashboards, notification feeds, and AI response streaming in Next.js.

자주 묻는 질문

“경로 처리기에서 보내는 서버 이벤트” 강의는 무료인가요?

네 — “경로 처리기에서 보내는 서버 이벤트” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

“경로 처리기에서 보내는 서버 이벤트”에서 뭘 배우나요?

경로 처리기에서 SSE 스트림과 재연결 로직으로 클라이언트에 실시간 업데이트를 전송하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“경로 처리기에서 보내는 서버 이벤트” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 경로 처리기에서 보내는 서버 이벤트
  2. 서버리스 환경에서 WebSocket 서비스 통합
  3. 토큰 단위로 인공지능 응답 스트리밍하기
  4. 사용자 현황, 커서와 실시간 협업 상태
← Next.js 15 Fullstack (App Router + Server Actions)(으)로 돌아가기