路由处理程序中的服务器发送事件
通过 SSE 流和路由处理程序中的重连逻辑,向客户端推送实时更新。
路由处理程序中的服务器发送事件 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 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
EventSourceAPI 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 withaddEventListener)id: <value>— optional cursor the browser sends back asLast-Event-IDon reconnectretry: <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
ResponsewithContent-Type: text/event-stream - Set
Cache-Control: no-cacheandConnection: keep-aliveso proxies do not buffer the stream - Pass a
ReadableStreamas 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 connectionsource.onmessage— receives unnamed (data:only) eventssource.addEventListener('name', fn)— receives named eventssource.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
cancelhook for belt-and-suspenders cleanup - Always guard
controller.enqueueafter abort to avoidWritableStream closederrors
// 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
EventEmittersingleton 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-IDand replay missed events - If you want to stop reconnection (e.g. the session expired), close the connection with HTTP 204 No Content —
EventSourcewill 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 -Ndisables buffering so frames appear as they arrive-H 'Accept: text/event-stream'mimics whatEventSourcesends- Watch for the blank line between events — a missing blank line means events will not be parsed by the browser
- Press
Ctrl-Cto 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-streamframes withdata:,event:,id:, andretry:fields separated by blank lines - Route Handler: return a
ReadableStreamwithContent-Type: text/event-streamandCache-Control: no-cacheheaders; exportdynamic = 'force-dynamic' - Cleanup: use both
ReadableStream cancel()andreq.signalabort listener to clear intervals and avoid memory leaks - Resumability: assign incremental IDs, read
Last-Event-IDon reconnect, and replay missed events from a ring buffer - Client:
EventSourcein a Client ComponentuseEffect; callsource.close()on unmount - Fan-out: share an
EventEmittersingleton (single-instance) or Redis Pub/Sub (multi-instance) across Route Handler invocations - Auth: pass tokens via query params or cookies; return
204to 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.
常见问题解答
「路由处理程序中的服务器发送事件」课时是免费的吗?
是的 — 「路由处理程序中的服务器发送事件」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
「路由处理程序中的服务器发送事件」这节课中我会学到什么?
通过 SSE 流和路由处理程序中的重连逻辑,向客户端推送实时更新。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。
「路由处理程序中的服务器发送事件」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?
能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 路由处理程序中的服务器发送事件
- 在无服务器环境中集成 WebSocket 服务
- 逐令牌流式传输人工智能响应
- 在线状态、光标与实时协作状态