Astro 5.2 の Server Islands で通知バッジを作り、SSE でリアルタイム更新した
Astro の server:defer で通知カウントを遅延レンダリングし、SSE エンドポイントで差分更新する実装パターンの記録
title: “Astro 5.2 の Server Islands で通知バッジを作り、SSE でリアルタイム更新した” description: “Astro の server:defer で通知カウントを遅延レンダリングし、SSE エンドポイントで差分更新する実装パターンの記録” slug: “astro-server-islands-sse-notification-badge” category: “web-development” tags: [“Astro”, “Server Islands”, “SSE”, “リアルタイム通知”, “server:defer”] publishedAt: 2026-07-22 featured: false
ダッシュボードの右上にある通知バッジ——あの赤い丸の数字を、Astro で作ろうとしたときの話。
ページ本体は静的でいい。ナビゲーション、サイドバー、コンテンツ領域、全部ビルド時に確定する。でも通知カウントだけはユーザーごとに違うし、リアルタイムで変わってほしい。この「ページの99%は静的、1%だけ動的かつリアルタイム」という要件に、Server Islands の server:defer がちょうどはまった。
server:defer で通知カウントを島にする
Server Islands は Astro 5.0 で安定版になった機能で、5.2 でも基本的な仕組みは変わっていない。コンポーネントに server:defer を付けると、そのコンポーネントだけがページ初期レンダリングから外れて、ブラウザ側から個別に取得される。
---
// src/components/NotificationBadge.astro
const res = await fetch(`${import.meta.env.API_URL}/notifications/count`, {
headers: { Authorization: `Bearer ${Astro.cookies.get('token')?.value}` }
});
const { unread } = await res.json();
---
<span class="badge" id="notif-count" data-count={unread}>
{unread > 0 && <span class="dot">{unread > 99 ? '99+' : unread}</span>}
</span>
これをレイアウトで使うとき、server:defer とフォールバックを指定する。
<!-- src/layouts/Dashboard.astro -->
<nav>
<NotificationBadge server:defer>
<span slot="fallback" class="badge">
<span class="dot dot--loading"></span>
</span>
</NotificationBadge>
</nav>
ページ本体は CDN でキャッシュしつつ、通知カウントだけサーバーに問い合わせが飛ぶ。ここまでは Server Islands の基本どおり。
WebSocket ではなく SSE を選んだ理由
最初は WebSocket で通知をプッシュしようとした。が、Astro には WebSocket のネイティブサポートがない。GitHub の roadmap discussion #695 で議論は続いているものの、2026年7月時点でも公式 API は未実装。アダプター経由で Deno や Cloudflare の WebSocket API を直接叩くことはできるが、型安全性が崩れるし、開発サーバーでのテストが面倒になる。
通知バッジの要件を整理すると、「サーバーからクライアントへの一方向プッシュ」で十分だった。クライアントから送るのは最初の接続だけ。であれば Server-Sent Events(SSE)のほうが筋がいい。
| 観点 | WebSocket | SSE |
|---|---|---|
| 方向 | 双方向 | サーバー→クライアント |
| Astro 対応 | 非公式(アダプター依存) | API Route で実装可能 |
| 自動再接続 | 自前実装 | EventSource が標準で対応 |
| HTTP/2 対応 | 別コネクション | 多重化される |
通知のように「新着があったらサーバーから教える」用途なら、SSE で十分。
SSE エンドポイントの実装
Astro の API Route で SSE エンドポイントを作る。src/pages/api/notifications/stream.ts に置く。
// src/pages/api/notifications/stream.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ cookies }) => {
const token = cookies.get('token')?.value;
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: Record<string, unknown>) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
// 初回は現在の未読数を送る
const initial = await getUnreadCount(token);
send({ type: 'count', unread: initial });
// 以降はポーリングで変更を検知(本番ではRedis Pub/Subなど)
const interval = setInterval(async () => {
try {
const count = await getUnreadCount(token);
send({ type: 'count', unread: count });
} catch {
clearInterval(interval);
controller.close();
}
}, 5000);
// クライアント切断時のクリーンアップ
// ReadableStreamのcancelで拾う
},
cancel() {
// interval のクリーンアップはここで
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
},
});
};
cancel コールバックでクリーンアップする設計だが、ここで一つはまった。setInterval の参照を start と cancel の間で共有するには、外側で変数を宣言しておく必要がある。start 内で const interval = ... と書くと cancel からアクセスできない。地味だが、SSE エンドポイントを初めて書くと確実に踏む。
修正版:
const stream = new ReadableStream({
start(controller) {
let intervalId: ReturnType<typeof setInterval>;
const send = (data: Record<string, unknown>) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
intervalId = setInterval(async () => {
const count = await getUnreadCount(token);
send({ type: 'count', unread: count });
}, 5000);
// cancelから参照できるようにcontrollerに紐づける
(controller as any)._intervalId = intervalId;
},
cancel(controller) {
clearInterval((controller as any)._intervalId);
}
});
……と書いたが、正直これは綺麗ではない。AbortController を使って signal でまとめて止めるほうが実務的には安全。
クライアント側で通知バッジを更新する
Server Islands のコンポーネントはサーバーで HTML をレンダリングして返すので、クライアント側の JavaScript は別途書く必要がある。自分は <script> タグで直接書いた。
<!-- src/components/NotificationBadge.astro の末尾に追加 -->
<script>
const badge = document.getElementById('notif-count');
if (!badge) throw new Error('badge not found');
const es = new EventSource('/api/notifications/stream');
es.onmessage = (event) => {
const { unread } = JSON.parse(event.data);
const dot = badge.querySelector('.dot');
if (unread > 0) {
if (dot) {
dot.textContent = unread > 99 ? '99+' : String(unread);
} else {
const span = document.createElement('span');
span.className = 'dot';
span.textContent = unread > 99 ? '99+' : String(unread);
badge.appendChild(span);
}
} else {
dot?.remove();
}
};
es.onerror = () => {
// EventSource は自動で再接続を試みる
// 明示的に閉じたいなら es.close()
};
</script>
EventSource の自動再接続は地味にありがたい。WebSocket だと onclose で再接続ロジックを自前で書く必要があるが、SSE なら標準で数秒後にリトライしてくれる。ネットワークが不安定なモバイル環境では、この差が効く。
5.2 の astro:config で環境ごとのエンドポイント管理
Astro 5.2 で実験的に追加された astro:config 仮想モジュールを使うと、設定値をコンポーネントから型安全に参照できる。自分のケースでは trailingSlash の設定をクライアント側で参照して、SSE のエンドポイント URL を組み立てるのに使った。
// astro.config.mjs
export default defineConfig({
trailingSlash: 'never',
experimental: {
serializeConfig: true,
},
});
import { trailingSlash } from 'astro:config/client';
const sseUrl = trailingSlash === 'always'
? '/api/notifications/stream/'
: '/api/notifications/stream';
正直、この程度なら直接書いても問題ない。ただ、複数の SSE エンドポイントを持つ場合や、開発・本番で base パスが変わる場合には、設定値を一箇所から引ける安心感がある。
実際に動かしてみて
手元の検証環境(Node.js アダプター、同一オリジン)で動かした結果:
- ページ初期表示は CDN キャッシュが効いて速い。Server Island の通知バッジだけ後から描画されるが、フォールバックにローディングドットを置いているので違和感は少ない
- SSE 接続は安定して維持される。5秒ごとのポーリングでカウント更新が反映されるまでの体感は「ほぼリアルタイム」で、通知用途としては十分
EventSourceの自動再接続は、ブラウザのタブを非アクティブにしてから復帰したとき地味に効いた。WebSocket だと切断後の復帰処理を自分で書いていたので、コード量は明らかに減った
本番でやるなら、サーバー側のポーリングを Redis Pub/Sub や PostgreSQL の LISTEN/NOTIFY に置き換えて、変更があったときだけイベントを送るようにする。5秒間隔のポーリングは検証用の妥協。
Server Islands と SSE の組み合わせは、「初期表示はサーバーレンダリング、以降はストリーミング更新」という分業がきれいにはまるパターンだった。WebSocket が必要になるのは、チャットや共同編集のように「クライアントからもデータを送りたい」場合。通知バッジのように受け取るだけなら、SSE のほうが実装もデプロイも楽だと感じた。