Astro Server Islands で画面を段階的に組み立てる
server:defer を複数配置したときの読み込み順序、フォールバック設計、props サイズの罠など、実務で踏んだポイントを整理した
発端:ダッシュボードの「白い画面」問題
案件で管理画面を Astro + SSR で組んでいたとき、ページを開くと一瞬すべてが白くなる現象が出た。ヘッダー、サイドバー、通知バッジ、メインのテーブル——全部がサーバーで一括レンダリングされていて、一番遅い DB クエリに引きずられて画面全体が待ちになっていた。
Server Islands の server:defer はこれを解決するための仕組みで、コンポーネント単位で「あとから差し込む」ことができる。ただ、複数の island を1ページに並べたとき、思ったほど単純にはいかなかった。その辺りの実務的な話を書く。
server:defer の動き方をまず押さえる
server:defer を付けたコンポーネントは、ビルド時に本体が取り除かれ、代わりに軽いスクリプトが埋め込まれる。ブラウザがページを読み込むと、そのスクリプトが /_server-islands/ComponentName のようなエンドポイントに GET リクエストを飛ばして HTML を取得し、slot に差し込む。
---
// src/pages/dashboard.astro
import Header from '../components/Header.astro';
import Sidebar from '../components/Sidebar.astro';
import NotificationBadge from '../components/NotificationBadge.astro';
import ActivityTable from '../components/ActivityTable.astro';
---
<Header />
<div class="layout">
<Sidebar server:defer>
<nav slot="fallback" class="sidebar-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
<div class="skeleton-line" />
</nav>
</Sidebar>
<main>
<NotificationBadge server:defer>
<span slot="fallback" class="badge-placeholder">--</span>
</NotificationBadge>
<ActivityTable server:defer>
<div slot="fallback" class="table-skeleton">
<!-- 5行分のスケルトン -->
{Array.from({ length: 5 }).map(() => (
<div class="skeleton-row" />
))}
</div>
</ActivityTable>
</main>
</div>
ここで大事なのは、各 island が並列で独立してフェッチされる点。Sidebar の裏で重い処理が走っていても、NotificationBadge は先に表示される。逆に言えば、読み込み順序を開発者が直接制御することはできない。速い順に勝手に埋まっていく。
フォールバックで「ガタつき」を消す
並列で読み込まれること自体は良いのだけど、フォールバックの設計が雑だと CLS(Cumulative Layout Shift)が発生する。実際にやらかした例:
<!-- ダメな例:高さが確保されていない -->
<ActivityTable server:defer>
<p slot="fallback">読み込み中...</p>
</ActivityTable>
テキスト1行分の高さしかないフォールバックが、テーブルに差し替わるときにページがガクッとずれる。
対策はシンプルで、フォールバックの高さを本番コンテンツに近づける。スケルトン UI を使うのが定番だけど、そこまで凝らなくても min-height を CSS で指定するだけでだいぶ違う。
.table-skeleton {
min-height: 320px; /* 実際のテーブルと揃える */
background: var(--skeleton-bg);
border-radius: 8px;
}
手元のプロジェクトでは、スケルトンの高さを揃えたあと CLS がほぼゼロになった。地味だけど、ここをサボるとユーザーが「画面が壊れた」と感じる。
props のサイズに引っかかる
Server Islands は props を暗号化して URL のクエリストリングに乗せる。ブラウザの URL 長制限(実質 2048 バイト)を超えると、Astro は自動的に POST に切り替える。POST になるとブラウザキャッシュが効かなくなる。
最初これを知らずに、フィルター条件のオブジェクトをまるごと props で渡していた。
<!-- やりがちだけど危ない -->
<ActivityTable server:defer filters={complexFilterObject} columns={allColumns} />
props が大きくなるほど GET → POST に切り替わる確率が上がり、同じページを再訪問してもキャッシュが効かずに毎回サーバーにリクエストが飛ぶ。
解決策は、props を最小限にすること。ID だけ渡してコンポーネント内部でデータを取得する形にした。
<!-- props は必要最低限に -->
<ActivityTable server:defer projectId={project.id} view="compact" />
コンポーネント側で Astro.props.projectId を受け取って DB を叩く。props が小さいと GET のままなのでキャッシュも効く。
Astro.url が返すのはページの URL ではない
地味にハマったのがこれ。server:defer のコンポーネント内で Astro.url を参照すると、ページの URL ではなく /_server-islands/ActivityTable のような island エンドポイントの URL が返ってくる。
パンくずリストの生成やアクティブなナビゲーションの判定で使っていたので、最初なぜ動かないのかわからなかった。
---
// server:defer コンポーネント内
// NG: island エンドポイントの URL が返る
const currentPath = Astro.url.pathname;
// OK: リクエスト元ページの URL を取得
const referer = Astro.request.headers.get('Referer');
const currentPath = referer ? new URL(referer).pathname : '/';
---
公式ドキュメントにも書いてあるが、server:defer を初めて使うと見落としがち。
「全部 defer」が正解ではない
調子に乗ってページ内のほぼ全コンポーネントに server:defer を付けたことがある。リクエスト数が膨れ上がって、逆に遅くなった。
Server Islands が効くのは、ページの大部分が静的で、一部だけ動的という構成のとき。動的コンテンツの比率が高いダッシュボードでは、むしろページ全体を SSR にしたほうが1回のリクエストで済む分速い。
自分の中での判断基準:
- ページの 7〜8 割が静的 →
server:deferで動的部分だけ切り出す - 動的部分がページの半分以上 → 普通に SSR にする
- 1ページの island が 5個を超える → 本当に全部必要か見直す
マルチリージョンと暗号化キー
本番でやらかしかけた話。Server Islands の props 暗号化キーはビルドごとにランダム生成される。ローリングデプロイで新旧のサーバーが混在すると、フロントエンドが新しいキーで暗号化した props を古いサーバーが復号できずにエラーになる。
# 固定キーを生成して環境変数にセットする
npx astro create-key
# 出力されたキーを ASTRO_KEY として設定
CDN でページをキャッシュしている場合も同様。キャッシュに残った HTML の中の暗号化 props と、新しくデプロイされたサーバーのキーが一致しなくなる。ASTRO_KEY を固定しておくことで回避できる。
結局のところ
Server Islands の段階的読み込みは、「静的シェルを即座に見せて、動的な部分をあとから埋める」という一点に尽きる。React の Suspense に似た概念だけど、各 island が独立した HTTP リクエストとして分離される点が違う。
使いどころを間違えなければ体感速度は明確に良くなる。ただし props のサイズ、フォールバックの高さ、Astro.url の挙動、暗号化キーの管理——この辺りは公式ドキュメントを読んでいても実際にやってみないと踏まない罠だった。