المستوى: متوسط — يفترض إنك تعرف Next.js App Router و async/await و Promises، ومش شرط تكون لمست WebSockets قبل كده.
لو تطبيقك محتاج يعرض إشعار للمستخدم في نفس اللحظة اللي بيحصل فيها الحدث — تعليق جديد، رد على رسالة، تحديث حالة طلب — أغلب التيوتوريالز هتقولك ركّب Socket.IO أو ws. التركيب ده بيحتاج سيرفر منفصل، ضبط sticky sessions على الـ load balancer، ومشاكل في النشر على Serverless. Server-Sent Events بيحل نفس المشكلة بـ Route Handler عادي في Next.js 15، بدون أي حزمة خارجية، وبيشتغل على Vercel و Cloudflare من غير إعداد إضافي.
المشكلة باختصار
الـ HTTP العادي عميل بيطلب، سيرفر بيرد، الاتصال بيتقفل. لو عايز السيرفر يقول للعميل "في حاجة جديدة" بدون ما العميل يسأل، عندك تلات اختيارات: Polling كل ثانية (مكلف وبطيء)، WebSockets (ثنائي الاتجاه ومعقّد)، أو SSE (أحادي الاتجاه من السيرفر للعميل وبسيط).
لما الإشعارات بتروح في اتجاه واحد بس — من السيرفر للمتصفح — SSE هو الاختيار العملي. الفرضية هنا: عندك تطبيق Next.js 15 على App Router، وبتبعت أقل من 100 إشعار/ثانية لكل مستخدم.
SSE بمثال جرس البيت
تخيّل إن عندك جرس بيت ذكي. الطريقة الغلط إنك تروح كل دقيقة تفتح الباب وتشوف لو حد جاي. الطريقة الصح إن الجرس نفسه يدقّ لما حد يضغط عليه. الـ Polling هو فتح الباب كل دقيقة. الـ SSE هو إن الجرس بيدقّ من نفسه.
لكن الجرس ده له خاصية مهمة: السلك بيوصّل من الزرّ بره للجرس جوّه — اتجاه واحد. الزائر مش بيقدر يبعتلك رسالة عبر الجرس. لو محتاج محادثة، هتفتح الباب وتتكلّم وجه لوجه — ده WebSocket.
التعريف العلمي الدقيق
Server-Sent Events هو معيار W3C ضمن HTML Living Standard، بيعرّف بروتوكول لإرسال أحداث نصية من السيرفر للمتصفح عبر اتصال HTTP طويل العمر. الاتصال أحادي الاتجاه (server → client)، بيستخدم Content-Type قيمته text/event-stream، وكل حدث بيتنسّق كنص بالشكل: data: payload\n\n. المتصفح بيدير الاتصال عبر EventSource API، وبيعمل reconnection تلقائي لو الاتصال اتقطع، مع دعم event IDs لاستئناف من آخر حدث وصل عبر هيدر Last-Event-ID.
الكود — Route Handler في Next.js 15
هنبني endpoint بيبعت إشعار كل ما حدث جديد يحصل. هنستخدم ReadableStream من Web Streams API — Next.js 15 بيدعمها على Edge و Node runtime.
// app/api/notifications/stream/route.ts
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const userId = req.nextUrl.searchParams.get('userId');
if (!userId) return new Response('userId required', { status: 400 });
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
send('connected', { userId, ts: Date.now() });
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': keep-alive\n\n'));
}, 15000);
const sub = subscribeToNotifications(userId, (notif) => {
send('notification', notif);
});
req.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
sub.unsubscribe();
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}