Astro v5 Server Islands で静的ページに動的領域を差し込む

Astro v5 の Server Islands を使うと、静的に配信される HTML の中に「サーバーで毎回レンダリングされる小さな穴」を空けられます。CDN キャッシュの利点を捨てずに、ログインユーザー名・カート個数・残在庫のようなパーソナライズ領域だけ動的にする実装パターンを、コピペで動くコードでまとめます。

「LP 全体は CDN にキャッシュさせたいけど、ヘッダーのログイン名と “カートに 3 件” だけはユーザーごとに変えたい」 — Astro v4 までは、これを実現しようとすると ページ全体を SSR に倒すしかありませんでした。Astro v5 で正式追加された Server Islands は、この典型的なジレンマを HTML レベルで解消してくれます。

本記事では、Cloudflare Workers アダプタ前提で、Server Islands を実プロダクトに投入するときの最小構成と、実運用で踏みやすい落とし穴をまとめます。

Server Islands は何をするのか

ざっくり言うと「静的 HTML の中に slot を空けておき、ブラウザがそこだけサーバーから差分取得する」仕組みです。

  • ページ本体は output: "static" 相当でビルドされ、CDN にキャッシュされる
  • server:defer を付けたコンポーネントだけ、別エンドポイント (/_server-islands/<name>) としてサーバーに残る
  • ブラウザは HTML 受信後にそのエンドポイントを fetch し、プレースホルダーを置換する

つまり「ページ全体は edge cache、パーソナライズ領域だけ origin で都度生成」を 1 つのページで両立できます。getStaticPaths で頑張って ISR っぽいことをするより、ずっと素直な解です。

到達点と前提

作るもの: 商品詳細ページ。本体は完全に静的キャッシュさせ、ヘッダーの ようこそ ◯◯ さんカートに N 件 だけサーバーで毎回描画する構成です。

検証バージョン (2026-04 時点): Astro v5.6 / @astrojs/cloudflare v12.2 / Wrangler v4.84。

client ── HTTPS ──▶ CDN (HIT, 静的 HTML)
                    └─ <astro-island server:defer>
                         └─ fetch /_server-islands/CartBadge
                              └─ Workers (毎回実行)

設定: アダプタとレンダリングモード

Server Islands を使うには、最低 1 つ以上 SSR 可能なルートが必要です。Cloudflare Workers アダプタを output: "server" で入れて、ページ側で prerender = true にする「ハイブリッド」が、実運用上は一番扱いやすい構成になります。

// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";

export default defineConfig({
  output: "server",
  adapter: cloudflare({
    imageService: "compile",
  }),
});

ページ本体は prerender、Island は server:defer

商品詳細ページは prerender = true で静的化し、その中に server:defer のコンポーネントを差し込みます。

---
// src/pages/products/[slug].astro
import Layout from "../../layouts/Layout.astro";
import CartBadge from "../../components/CartBadge.astro";
import { getProduct, getAllSlugs } from "../../lib/products";

export const prerender = true;

export async function getStaticPaths() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ params: { slug } }));
}

const { slug } = Astro.params;
const product = await getProduct(slug!);
---

<Layout title={product.title}>
  <header>
    <a href="/">r43shop</a>
    <CartBadge server:defer>
      <span slot="fallback" class="badge-placeholder">—</span>
    </CartBadge>
  </header>

  <article>
    <h1>{product.title}</h1>
    <p>{product.description}</p>
    <p class="price">¥{product.price.toLocaleString()}</p>
  </article>
</Layout>

ポイントは 3 つです。

  1. export const prerender = true — このページの本体は build 時に HTML 化され、CDN にキャッシュされる
  2. server:deferCartBadge だけは別エンドポイントとして残り、ブラウザが後追いで fetch する
  3. <span slot="fallback"> — Island がロードされるまでの数十 ms に表示される最初の表示。CLS を避けるため、必ずサイズが固定されるダミーを置く

Island 側はリクエストごとに評価される

CartBadge.astro 自体は普通の Astro コンポーネントですが、Astro.request / Astro.cookies がリクエストごとに変わります。

---
// src/components/CartBadge.astro
import { getSessionUser } from "../lib/auth";
import { countCartItems } from "../lib/cart";

const sid = Astro.cookies.get("sid")?.value;
const user = sid ? await getSessionUser(sid) : null;
const count = user ? await countCartItems(user.id) : 0;
---

{user ? (
  <span class="badge">
    ようこそ {user.displayName} さん / カートに {count} 件
  </span>
) : (
  <a href="/login" class="badge">ログイン</a>
)}

このコンポーネントは Workers 上で毎リクエスト実行されますが、ページ本体の HTML は CDN から HIT で返るので、TTFB は静的ページと変わりません。Island が後追いで埋まる UX は、十分速いセッションストアと組み合わせれば体感ほぼゼロ遅延になります。

キャッシュヘッダの分離

「ページ本体は public, max-age=3600」「Island は private, no-store」のように、ヘッダ戦略を分けないと Server Islands の意味が消えます

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (ctx, next) => {
  const res = await next();
  if (ctx.url.pathname.startsWith("/_server-islands/")) {
    res.headers.set("Cache-Control", "private, no-store");
  }
  return res;
});

Cloudflare 側でも、商品詳細ページに対しては Cache-Control: public, s-maxage=3600 を返し、Island のエンドポイントには絶対にキャッシュを効かせない、という二段階設定にしておきます。

落とし穴 / ハマりポイント

実運用で踏んだ典型的なポイントを 4 つ。

  • prerender = true を付け忘れると Island の意味が無い。ページ全体が SSR に落ちると CDN HIT が消えるので、Server Islands 化したのに体感が遅くなることがあります。Lighthouse の TTFB と cf-cache-status: HIT の有無で必ず確認してください
  • Island の中で Astro.url を使うとページ URL ではなく Island エンドポイントの URL が取れる。商品 slug などをページ側から渡したいときは props で明示的に渡す
  • server:defer の中ではクライアント JS は普通には動かない。Island は HTML 断片として返ってくるので、client:load を入れ子にしたい場合は別途設計が必要です
  • ローカルの astro dev ではキャッシュ HIT を再現できないastro build && wrangler pages dev dist か、本番デプロイで cf-cache-status を見ながら検証する。dev サーバの体感速度を信用しないこと

どこに効くのか — 効果の出る箇所、出ない箇所

Server Islands が劇的に効くのは「ページ本体は重いが、パーソナライズ部分は軽い」ケースです。

  • 効く: 商品詳細・ブログ記事・ドキュメント — ページ本体が重く、ヘッダや CTA だけ動的
  • 効かない: ダッシュボード — そもそもほぼ全領域が動的なので、普通に SSR したほうが素直
  • 微妙: 検索結果ページ — クエリごとに本体も変わるので、CDN HIT 率が低い

「8 割は CDN で返したいけど、2 割だけ動的にしたい」が境界線の感覚です。

まとめ

Astro v5 Server Islands は「静的サイトと SSR の二項対立」に終止符を打つ、地味だが効く機能です。CDN HIT を捨てずにパーソナライズ領域を差し込めるので、これまで「ページ全体を SSR に倒すかどうか」で悩んでいた個人開発のサイトには素直にハマります。

次回は Server Islands と Cloudflare の HTMLRewriter / Workers KV を組み合わせて、A/B テストの分岐を Island 化する話を書く予定です。

参考