zudo-paper

Astro dev-api, SSR, Server Islands等雑談

Author: Takazudo | 作成: 2026/04/12

概要

Takazudo Modularのガイド記事に埋め込んでいる「Pick Cover」ボタン (YouTube動画のサムネイルをdev環境だけで選び直す機能) の仕組みをClaudeに聞いていたら、Astroのアーキテクチャ全般 (SSRとSSGとServer Islandsの違い) の話に発展した。自分用のメモとしてそのまま残す。

Pick Cover実装のサマリ

会話の前提がこれなので軽く触れておく。Takazudo Modularのガイド記事には <YoutubeAt> というMDXコンポーネントがあり、dev環境のときだけ「Pick Cover」ボタンが出る。押すとダイアログで別のキャプチャ画像に選び直せて、POST /api/dev/youtube-at-pick を叩くと、Astro dev serverが該当MDXファイルの capture="..." 属性を直接書き換える (temp fileにwriteしてからatomicに fs.rename)。

これはややこしいが、YouTube動画を共にした記事執筆時、AIの自動判断ではピッタリの画像を選ぶことが困難であるため、pnpm dev起動時には候補となるキャプチャ画像一覧をダイアログで全部表示させ、人の手でそれを選択。そのままMDXファイルを更新させるという仕組み(を相談してたらClaude Codeがそういう風に作った)

Pick Coverダイアログ

実装はAstroの APIRoute + injectRoute を使ったカスタムインテグレーション (src/astro/integrations/dev-api.ts)。エンドポイントファイルは src/pages/ の外 (src/astro/lib/dev-api/) に置いてあって、本番ビルドには混入しない。command === 'dev' のときだけ injectRoute を呼ぶので astro build では完全にno-op。Vite自体の機能ではなく、AstroがViteの上に載せている APIRoute 機構をdev-only toolとして「転用」している形になっている。本番は output: 'static' なのでそもそもPOSTハンドラは使えないし、deploy先にはMDXファイル自体が存在しない。

以下、そのときの会話をほぼそのまま残す。

会話

Takazudo
Takazudo
Takazudo

our YoutubeAt has “Pick Cover” button, and it opens dialog, then we can pick new cover. does this reflect to the mdx instantly? what tech are we using for this?

YoutubeAtに「Pick Cover」ボタンがあって、押すとダイアログが開いて新しいcoverを選べるけど、これってMDXに即時反映されるんですか? どんな技術を使っているんでしょうか?

Claude Code
Claude Code
Claude Code

Yes — picking a cover writes to the MDX on disk immediately, and the dev server then hot-reloads it. Here’s the exact chain:

Clientcomponents/mdx/youtube-at/youtube-at.tsx The <YoutubeAt> component renders a “Pick Cover” button only in dev + WIP mode (i.e. when videoDir is set and import.meta.env.DEV is true). Clicking it opens CaptureGalleryDialog; on confirm it:

  1. Reads the article slug from location.pathname (/guides/<slug>/).
  2. POST /api/dev/youtube-at-pick/ with { articleSlug, id, capture }.
  3. Also updates local React state so the cover refreshes instantly without waiting for HMR.

Serversrc/astro/lib/dev-api/youtube-at-pick.ts

  • Validates: slug [a-z0-9-]+, id [a-zA-Z0-9_-]+, capture capture-HH-MM-SS.jpg.
  • Opens src/mdx/guides/<slug>.mdx (and .en.mdx if present — skipped silently if missing).
  • Regex-matches the <YoutubeAt> block whose id="..." matches, then inserts or replaces the capture="..." attribute in-place.
  • Writes atomically: temp file → fs.rename() to final path.
  • Returns 404 if the id isn’t found, 200 with the paths on success.

Dev-only guardsrc/astro/integrations/dev-api.ts

  • Route is only injected when command === 'dev', so it never ships to production.
  • The handler file lives under src/astro/lib/dev-api/ outside src/astro/pages/ so Astro’s router can’t accidentally pick it up in a build.
  • Defense in depth: also checks import.meta.env.DEV at runtime.

Change propagation — no custom watcher. Vite’s built-in file watcher sees the .mdx change and Astro HMR refreshes the page. Because the client also patches local state, the user sees the new thumb immediately even before HMR lands.

Caveats

  • YouTubeCaptureWip uses a similar but separate flow (same dir pattern, its own dialog).
  • The regex rewriter is per-component-id, so two YoutubeAt with the same id in one MDX would be ambiguous — ids need to be unique within a file.
  • No auth / no conflict handling — fine for local dev, not safe to expose.
  • Matches only .mdx in src/mdx/guides/, so this flow is guide-scoped (notes/highlights wouldn’t be touched by it as written).

はい — cover選択は即座にディスク上のMDXへ書き込まれ、その後dev serverがホットリロードします。正確な処理の流れは以下のとおりです。

Clientcomponents/mdx/youtube-at/youtube-at.tsx <YoutubeAt> コンポーネントはdev + WIPモードのときだけ「Pick Cover」ボタンを描画します(videoDir が設定されていて import.meta.env.DEV が true のとき)。クリックすると CaptureGalleryDialog が開き、確定時に以下を行います。

  1. location.pathname/guides/<slug>/)から記事slugを読み取る。
  2. POST /api/dev/youtube-at-pick/{ articleSlug, id, capture } を送る。
  3. ローカルのReact stateも更新し、HMRを待たずに即座にcoverが更新されるようにする。

Serversrc/astro/lib/dev-api/youtube-at-pick.ts

  • バリデーション: slugは [a-z0-9-]+、idは [a-zA-Z0-9_-]+、captureは capture-HH-MM-SS.jpg
  • src/mdx/guides/<slug>.mdx(および存在すれば .en.mdx、無ければ黙って無視)を開く。
  • id="..." が一致する <YoutubeAt> ブロックを正規表現でマッチし、その場で capture="..." 属性を挿入または置換する。
  • アトミックに書き込む: tempファイル → fs.rename() で最終パスへ。
  • idが見つからなければ404、成功なら200でパスを返す。

Dev-only guardsrc/astro/integrations/dev-api.ts

  • ルートは command === 'dev' のときだけ注入されるため、productionへは出ない。
  • ハンドラファイルは src/astro/pages/src/astro/lib/dev-api/ に配置されており、Astroのルーターがビルド時に誤って拾うことはない。
  • さらに二重の防御: ハンドラ内で import.meta.env.DEV もチェックしている。

変更の伝播 — カスタムwatcherは無し。Viteの組み込みファイルウォッチャが .mdx の変更を検知し、AstroのHMRがページをリフレッシュします。クライアント側でもローカルstateを更新しているので、HMRより先にユーザーには新しいthumbが見えます。

注意点

  • YouTubeCaptureWip は似ているが別系統のフロー(同じディレクトリパターンだが、独自のダイアログ)。
  • 正規表現による書き換えはコンポーネントidベースなので、同じMDX内に同じidの YoutubeAt が2つあると曖昧になる。idはファイル内でユニークにする必要がある。
  • 認証なし、競合処理なし — ローカル開発では問題ないが、公開すると危険。
  • src/mdx/guides/ 内の .mdx にしかマッチしないので、notes/highlights側はこのフローでは触れない。
Takazudo
Takazudo
Takazudo

what is the backend of the api? vite has such feature?

そのAPIのバックエンドは何ですか? Viteにそんな機能あるんでしたっけ?

Claude Code
Claude Code
Claude Code

The backend is Astro, not raw Vite. Here’s what’s actually happening:

It’s Astro’s own API route system

Astro supports file-based API routes (like Next.js): any .ts file under src/pages/ that exports GET/POST/etc. functions becomes an HTTP endpoint. Those exports are typed as APIRoute from astro, and Astro handles the request/response plumbing (request.json(), new Response(...), status codes) — the Web fetch API shape.

Normally you’d just drop the file in src/pages/api/dev/youtube-at-pick.ts and be done. This project specifically avoids that, for a build-time reason explained next.

Why injectRoute + a custom integration

Astro’s default output is output: 'static' — everything is prerendered. A POST handler can’t be prerendered, so it must opt out with export const prerender = false. But then astro build refuses to run without an SSR adapter (Netlify, Node, etc.), failing with [NoAdapterInstalled]. The team didn’t want to install an adapter just to support a dev-only button.

The workaround, in src/astro/integrations/dev-api.ts:

  1. Custom Astro integration (devApiIntegration) hooks into astro:config:setup.
  2. It checks command — Astro passes 'dev' or 'build'.
  3. Only when command === 'dev' does it call injectRoute({ pattern: '/api/dev/youtube-at-pick', entrypoint: ... }).
  4. The entrypoint file is deliberately placed outside src/astro/pages/ (in src/astro/lib/dev-api/) so Astro’s file-based router can’t pick it up on its own. It only becomes a route because injectRoute adds it, and only in dev.

In astro build, the integration is a no-op → the file is never imported → prerender = false never triggers the adapter check → static build works cleanly.

So where does Vite fit?

Astro’s dev server is built on Vite — Vite handles module loading, HMR, and the HTTP server. But the API route concept (export const POST: APIRoute) is Astro’s abstraction on top of Vite, not a Vite feature. Vite by itself only ships middleware hooks (configureServer(server) { server.middlewares.use(...) }) — you’d have to wire req/res yourself. Astro’s APIRoute handler is the Web Request/Response shape wrapped over that.

Summary

LayerRole here
ViteDev server, file watcher, HMR (reloads the page after the MDX is rewritten)
AstroAPIRoute contract, injectRoute, integration lifecycle (astro:config:setup, command)
Custom integrationConditional injectRoute only when command === 'dev', so prod builds don’t need an SSR adapter
Node fsAtomic MDX rewrite (writeFile to temp → rename)

The double-guard (command !== 'dev' in the integration + import.meta.env.DEV in the handler) is specifically so a future refactor can’t accidentally expose this file-writing endpoint in production.

バックエンドは Astro で、生のViteではありません。実際に何が起きているかは以下のとおりです。

それはAstroのAPI routeシステムそのもの

AstroはNext.jsのようなファイルベースのAPIルートをサポートしています。src/pages/ 配下の .ts ファイルが GET/POST 等の関数をexportすると、そのままHTTPエンドポイントになります。これらのexportは astroAPIRoute として型付けされ、Astroがrequest/responseの配線(request.json()new Response(...)、ステータスコード)を担当します — Webの fetch APIと同じ形です。

普通なら src/pages/api/dev/youtube-at-pick.ts に置いて終わりなんですが、このプロジェクトは意図的にそれを避けています。ビルド時の理由があるので次で説明します。

なぜ injectRoute + カスタムインテグレーションなのか

Astroのデフォルト出力は output: 'static' で、全てが事前レンダリングされます。POSTハンドラは事前レンダリングできないので、export const prerender = false でオプトアウトする必要があります。しかしそうすると astro buildSSRアダプタ(Netlify、Node等)がないと動かず、[NoAdapterInstalled] で失敗します。dev専用のボタンのためだけにアダプタを入れたくなかったということです。

回避策は src/astro/integrations/dev-api.ts で:

  1. カスタムAstro integrationdevApiIntegration)が astro:config:setup にフックする。
  2. command をチェック — Astroは 'dev''build' を渡してくる。
  3. command === 'dev' のときだけ injectRoute({ pattern: '/api/dev/youtube-at-pick', entrypoint: ... }) を呼ぶ。
  4. エントリポイントファイルは意図的に src/astro/pages/src/astro/lib/dev-api/)に置かれているので、Astroのファイルベースルーターが勝手に拾うことはない。ルートになるのは injectRoute が追加したときだけ、しかもdev時のみ。

astro build ではこのintegrationはno-op → ファイルが読み込まれない → prerender = false も発動しない → アダプタチェックもかからない → static buildが問題なく通る。

で、Viteはどこに?

AstroのDev serverはViteの上に乗っています — Viteがモジュールロード、HMR、HTTP serverを担当します。しかしAPIルートの概念(export const POST: APIRoute)は Astroの抽象で、Vite自身の機能ではありません。Vite単体ではmiddlewareフック(configureServer(server) { server.middlewares.use(...) })しか提供されないので、req/res を自前で捌く必要があります。AstroのAPIRouteハンドラは、WebのRequest/Responseを包んだラッパーです。

まとめ

レイヤここでの役割
ViteDev server、ファイルウォッチャ、HMR(MDX書き換え後にページを再読み込み)
AstroAPIRoute 契約、injectRoute、インテグレーションのライフサイクル(astro:config:setupcommand
カスタムinteg.command === 'dev' のときだけ injectRoute を呼ぶため、prodビルドではSSRアダプタを必要としない
Node fsアトミックなMDX書き換え(writeFile → tempファイル → rename

二重のガード(integration側の command !== 'dev' チェック + handler側の import.meta.env.DEV)は、将来のリファクタでうっかりこのファイル書き込みエンドポイントがproductionに漏れ出るのを防ぐためです。

Takazudo
Takazudo
Takazudo

is that astro API designed to do this kind of while-in-dev purpose? it sounds like it will never work on the deployed without using Cloudflare workers or some other backend systems.

そのAstro APIは、こういうdev中だけの用途のために設計されたものなんですか? Cloudflare Workersか他のバックエンドシステムを使わない限り、デプロイ後は絶対動かなさそうに聞こえます。

Claude Code
Claude Code
Claude Code

You’ve put your finger on exactly the right tension. The short answer:

Astro API routes are general-purpose, but this project is repurposing them as a dev-only tool. You’re right that this exact handler could never run on a static deploy — that’s the whole point of the workaround.

What Astro API routes are actually for

Astro’s APIRoute is a general backend feature, designed for three production scenarios:

  1. Prerendered endpointsGET handlers that run at astro build and output static JSON files. Works on any static host. (e.g., /api/products.json baked at build time.)
  2. SSR endpoints — any handler with prerender = false, runs at request time, requires an adapter: @astrojs/node, @astrojs/netlify, @astrojs/cloudflare, @astrojs/vercel, etc. The adapter compiles the handler into whatever runtime the host supports (Node server, Netlify Function, Cloudflare Worker, Vercel Function, Deno Deploy).
  3. Hybrid — mix of the above.

So yes, a POST handler in production is always “SSR adapter + some backend.” There is no fourth mode.

Why this specific handler could never run in prod anyway

Even if the team installed @astrojs/netlify and let this POST deploy as a Netlify Function, it still wouldn’t work — for reasons that go beyond Astro:

  • The MDX source files don’t exist on the server. Netlify Functions only see the built dist/ output. src/mdx/guides/*.mdx is left behind on the build machine.
  • Serverless/edge filesystems are read-only or ephemeral. Cloudflare Workers have no filesystem at all. Netlify Functions have /tmp but it vanishes after the invocation and is per-container. fs.writeFile('src/...') is a no-op everywhere.
  • Even if you could write, there’s nothing to write to — you’d have to commit back to git via the GitHub API, go through a headless CMS, trigger a rebuild, etc. That’s a completely different architecture (a git-backed CMS like Decap/Netlify CMS, TinaCMS, etc.).

So what’s the pattern here, really?

It’s a local authoring tool that piggybacks on the dev server. The author is the only user, the “backend” is their own laptop, and the “database” is the MDX files in their working tree. The handler uses Astro’s APIRoute contract because:

  • It’s a clean Web Request/Response abstraction (nicer than raw Node req/res).
  • Astro already runs it in the same Vite process, so no separate server.
  • Integrations + injectRoute give a clean way to make it structurally impossible to leak into production (the file isn’t even imported during astro build).

A more “idiomatic Vite” alternative would be a Vite plugin with configureServer(server) { server.middlewares.use('/api/dev/...', handler) } — that’s what Vite’s plugin API is actually designed for (dev-only middleware). Functionally equivalent, slightly lower-level. The team chose Astro’s layer because this is an Astro project and the APIRoute ergonomics are nicer.

The general pattern name

In content-heavy static-site projects, this is often called a “dev-time CMS” or “local authoring endpoint”. You’ll see it in:

  • Astro/Next.js blogs with “edit this page” buttons that rewrite frontmatter in dev.
  • Storybook-style tools that save component states back to disk.
  • Tina CMS in its local-only mode.
  • Zola/Hugo projects with companion Go tools that edit TOML frontmatter.

They all share the same trick: the server is the author’s own dev machine, so you can freely fs.writeFile without any of the constraints of a deployed runtime. The deploy target stays 100% static.

Implication for your mental model

If you ever wanted something like the Pick Cover button to work on a deployed site (say, so a non-developer could edit cover images through a live URL), you’d need a totally different architecture:

  • SSR adapter (Node / Cloudflare / Netlify).
  • A persistent store — not the filesystem. Options:
    • Git-backed: POST commits to GitHub via the API, rebuild triggers on push. (TinaCMS, Decap.)
    • Runtime store: Cloudflare R2 / KV / D1, Netlify Blobs, a real database. But then MDX is no longer the source of truth — it’s just what gets rendered from the store at build time.
  • Authentication, because now the internet can hit it.
  • Preview/rollback, because you lose git’s safety net if you skip the commit path.

The current setup deliberately avoids all of that by saying: “this button only exists on Takazudo’s laptop, and the git commit afterward is the publish step.” It’s the right call for a solo-authored content site.

重要なポイントをピンポイントで突いています。短く答えると:

Astro APIルートは汎用機能ですが、このプロジェクトではそれを dev専用ツールとして転用 しています。おっしゃる通り、このハンドラそのものはstatic deployでは絶対に動きません — そしてそれがまさにこの回避策の目的です。

AstroのAPIルートが実際に想定している用途

AstroのAPIRouteは汎用のバックエンド機能で、productionでは3つのシナリオが想定されています。

  1. 事前レンダリングされたエンドポイントastro build 時に実行されて静的なJSONファイルを出力する GET ハンドラ。どんなstaticホストでも動く(例: ビルド時に焼いた /api/products.json)。
  2. SSRエンドポイントprerender = false をつけたハンドラはリクエスト時に実行され、アダプタが必須: @astrojs/node@astrojs/netlify@astrojs/cloudflare@astrojs/vercel など。アダプタがハンドラをそのホストのランタイム向けにコンパイルする。
  3. ハイブリッド — 上記の混合。

だからそう、productionでPOSTハンドラを使う場合は必ず「SSRアダプタ + 何らかのバックエンド」です。第4のモードは存在しません。

仮にSSRアダプタを入れても、このハンドラがprodで動くことはない

仮に @astrojs/netlify を入れてこのPOSTをNetlify Functionとしてデプロイしたとしても、以下の理由でやはり動きません。Astroを超えたレベルの話です:

  • MDXソースファイルはサーバー上に存在しない。 Netlify Functionsが見えるのはビルド済みの dist/ だけ。src/mdx/guides/*.mdx はビルドマシンに置き去り。
  • Serverless/edgeのファイルシステムはread-onlyか揮発性。 Cloudflare Workersにはファイルシステムそのものが無い。Netlify Functionsは /tmp が使えるが関数呼び出しごとに消え、コンテナごとに別物。fs.writeFile('src/...') はどこでも意味を成さない。
  • 仮に書けたとしても、書き込む先が無い — GitHub API経由でコミットしてheadless CMSを挟んで再ビルドを叩いて……となり、完全に別のアーキテクチャ(Decap/Netlify CMS、TinaCMSのようなgit-backed CMS)になる。

で、ここで使われているパターンは何か

これはdev serverに便乗したローカルauthoringツールです。ユーザーは著者1人だけ、「バックエンド」は著者の手元のラップトップ、「データベース」はworking tree内のMDXファイル。ハンドラがAstroの APIRoute を使っているのは以下の理由:

  • Web Request/Response のきれいな抽象が使える(生のNode req/res よりラク)。
  • AstroがすでにVite processの中で動かしてくれているので、別サーバーを立てる必要がない。
  • integrations + injectRoute構造的にproductionへ漏らさないように作れる(ファイルが astro build 時にそもそも読み込まれない)。

もっと「Vite流」にするなら、Vite pluginの configureServer(server) { server.middlewares.use('/api/dev/...', handler) } というパターンがあります。これはViteのプラグインAPIが想定しているdev専用middlewareのやり方。機能的にはほぼ等価で、レイヤが少し低いだけ。今回はAstroプロジェクトなので、AstroのAPIRouteの使い勝手を取った。

このパターンの一般的な呼び名

コンテンツ中心のstatic siteプロジェクトでは、このパターンは「dev-time CMS」あるいは「local authoring endpoint」と呼ばれます。以下のような場面で見かける:

  • Astro/Next.jsのブログで、dev中だけfrontmatterを書き換える「edit this page」ボタン。
  • コンポーネントの状態をディスクに保存し直すStorybook系のツール。
  • Tina CMSのlocal-onlyモード。
  • Zola/HugoプロジェクトでTOML frontmatterを編集するコンパニオンGoツール。

どれも共通するトリックは「サーバーは著者自身のdevマシン」という点。デプロイ先の制約に縛られずに fs.writeFile を自由に呼べる一方、デプロイ先は100% staticのまま。

あなたの頭の中のモデルに対する意味

もしPick Coverボタンをデプロイ済みのサイトで動かしたい(例えば開発者以外がライブURL経由でcover画像を編集したい)なら、まったく別のアーキテクチャが必要になります:

  • SSRアダプタ(Node / Cloudflare / Netlify)。
  • ファイルシステムではない永続ストア。候補は:
    • Git-backed: GitHub API経由でコミットPOST、pushでリビルドトリガー(TinaCMS、Decap)。
    • ランタイムストア: Cloudflare R2 / KV / D1、Netlify Blobs、本物のDB。ただしMDXはもはやsource of truthではなく、ストアからビルド時にレンダリングされる「表示用」に格下げされる。
  • 認証(インターネットに晒されるので)。
  • プレビュー/ロールバック(gitのsafety netが消えるので自前で用意)。

現状はそういうものを全て避けるために意図的に「このボタンはTakazudoのラップトップにだけ存在し、その後のgit commitが公開ステップである」という設計にしている。1人author向けのコンテンツサイトにとっては正しい判断。

Takazudo
Takazudo
Takazudo

I can understand that returns the static data easily. but what is Astro SSR? it triggers build? if so, it sounds like astro triggers the whole site build. Or do we need to one page build feature? is it possible with astro?

静的なデータを返すのは分かるんですが、Astro SSRって何なんですか? ビルドをトリガーするんですか? だとしたらAstroがサイト全体のビルドをトリガーするように聞こえるんですが。それとも1ページだけビルドする機能が必要? Astroでそれは可能ですか?

Claude Code
Claude Code
Claude Code

Great question — and the confusion is common because “SSR” and “build” are two different things that get conflated.

SSR ≠ build. SSR = per-request rendering.

Here’s the clean mental model:

ModeWhen HTML is generatedServer needed at runtime?Example
SSG / staticOnce, at astro buildNo — just a CDN serving filestakazudomodular.com today
SSROn every request, at request timeYes — an always-on function/serverTwitter timeline, dashboard
ISR / on-demandFirst request builds, subsequent requests reuse cached HTML until invalidatedYes, same as SSRNext.js blogs, Vercel-hosted sites

SSR does not trigger a build. The build still happens once, ahead of time — but what gets built is a server bundle (a JS module exporting route handlers), not static HTML files. Deploy that bundle to Node / Cloudflare Workers / Netlify Functions / Vercel Functions, and it sits there. When a request arrives at /guides/oxi-one-mkii-guide-ep9/, the handler runs right then, reads MDX (or DB, or whatever), renders HTML, returns it. Next request repeats. No rebuild.

So SSR is really “the page is a function, invoked per request.” It’s the same thing Express/Rails/Django do — Astro just gives it a nicer API and uses the framework to know how to bundle it.

What Astro actually gives you

Astro has a per-page toggle:

// src/pages/some-page.astro
export const prerender = true; // → static HTML at build time (SSG)
export const prerender = false; // → function, invoked per request (SSR)

Combined with output:

  • output: 'static'all pages prerendered. No SSR allowed (this is the zmod project today).
  • output: 'server'all pages SSR by default. Individual pages can opt into prerendering with prerender = true.
  • output: 'static' + per-page prerender = false (what Astro used to call “hybrid”, now just the default) — mix. Most pages are static, a handful are SSR. But as soon as one page is SSR, you need an adapter and an always-on runtime.

The moment you have any SSR page, every request to that page goes through the function at runtime. Not a build.

”Can Astro rebuild just one page?” — ISR territory

No, Astro core does not have a “rebuild one page” feature. What you’re describing is Incremental Static Regeneration (ISR), which Next.js popularized.

ISR works like this:

  1. Page was built statically at astro build, cached somewhere (CDN, function cache).
  2. Request comes in — CDN serves the cached HTML instantly.
  3. If the cache is older than N seconds (or has been manually invalidated), the runtime regenerates that single page in the background, swaps in the new version, and serves the new version to the next request.

ISR is a property of the hosting platform’s adapter, not Astro itself:

  • Vercel adapter (@astrojs/vercel) — supports ISR via isr: { expiration: 60 } in the adapter config. You can also mark pages as on-demand. This is the closest thing to what you’re imagining.
  • Netlify adapter (@astrojs/netlify) — supports on-demand builders (now called “persistent functions”) with stale-while-revalidate semantics.
  • Cloudflare adapter — no ISR in the traditional sense. You’d do caching via Cache API / KV manually.
  • Node adapter — no ISR. You’d implement cache yourself.

So: “rebuild one page when content changes” is possible, but only on platforms whose adapter layers ISR on top of Astro. And even then, the “rebuild” is a single page handler re-running, not a whole-site astro build.

Why this matters for the “Pick Cover” scenario

Let’s trace what would have to be true for that button to work on the deployed site:

OptionWhat happensFits Astro how?
A. Full static + git-backed CMSButton commits to GitHub → Netlify build hook → full astro build (~3–4 min) → CDN updatesCurrent architecture. Works, but the edit isn’t instant.
B. SSR the affected pagesMark /guides/[slug] as prerender = false, store covers in a DB/KV, read on every requestEvery guide request now runs a function. Slower, more infra, but edits are instant.
C. SSR + ISR on Vercel/NetlifyGuide pages are static-by-default, cached at the edge; the POST handler invalidates just that slug; next request regenerates that one pageNeeds a platform adapter that supports on-demand invalidation. Astro core doesn’t give you this directly.
D. Client-side fetch of dynamic dataMDX ships static, but the cover image URL is fetched from a separate /api/covers/<id> endpoint on page loadKeeps most of the site static. Only the API endpoint is SSR.

Option D is actually what most content-heavy Astro sites do when they need a little dynamic behavior: keep SSG for the page shell, sprinkle a handful of SSR endpoints for the small pieces that need to be live.

So what IS SSR good for?

Concrete examples where SSR pays off:

  • Auth-gated pages/account/orders must see the logged-in user; can’t be prebuilt.
  • Personalization — A/B tests, geo-based content, user-specific recommendations.
  • Long-tail dynamic routes — e-commerce sites with millions of products where prebuilding all of them would take hours. Build the top 1000, SSR the rest.
  • Search result pages/search?q=foo, infinite combinations, can’t prerender.
  • Preview mode — draft content from a CMS, rendered on-demand for editors.

For a shop catalog with ~hundreds of products and guide articles that change a few times a week, SSG + periodic rebuild is the right answer. SSR would be all downside — slower, more infra, no real benefit.

TL;DR

  • SSR = function invoked per request. Does not trigger a build.
  • Build happens once, ahead of time, producing either static HTML (SSG) or a server bundle (SSR). Usually both.
  • “Rebuild one page” is ISR, which is an adapter/platform feature (Vercel, Netlify persistent functions), not core Astro.
  • The site stays 100% static in production precisely because none of this is worth the complexity for a content site — the “Pick Cover” trick sidesteps it entirely by pushing all the mutation into dev mode.

いい質問です。「SSR」と「ビルド」は別物ですが、混同されがちなので丁寧に解いていきます。

SSR ≠ ビルド。SSR = リクエストごとのレンダリング。

きれいなメンタルモデルはこれです:

モードHTMLが生成されるタイミング実行時にサーバーが必要か
SSG / staticastro build 時に一度だけ不要 — CDNで配信するだけ今の takazudomodular.com
SSRリクエストのたびに、リクエスト時必要 — 常駐関数 or サーバーTwitterのタイムライン、ダッシュボード
ISR / on-demand最初のリクエストでビルド、以降はキャッシュHTMLを再利用、無効化されたら再生成必要(SSRと同じ)Next.jsブログ、Vercelサイト

SSRはビルドをトリガーしません。ビルド自体は事前に1回だけ行われますが、作られるのは静的HTMLではなく サーバーバンドル(ルートハンドラをexportするJSモジュール)です。それをNode / Cloudflare Workers / Netlify Functions / Vercel Functionsにデプロイすると、常駐状態で待機します。/guides/oxi-one-mkii-guide-ep9/ にリクエストが来ると、その瞬間にハンドラが実行され、MDX(あるいはDB等)を読み、HTMLをレンダリングして返します。次のリクエストでも同じ。リビルドはゼロ。

だからSSRの本質は「ページは関数で、リクエストごとに呼ばれる」。Express/Rails/Djangoと同じことです。Astroはそれに綺麗なAPIを被せて、バンドル方法を把握しているだけ。

Astroが実際に提供するもの

Astroにはページ単位のトグルがあります:

// src/pages/some-page.astro
export const prerender = true;  // → ビルド時の静的HTML (SSG)
export const prerender = false; // → リクエスト時呼び出しの関数 (SSR)

output と組み合わせて:

  • output: 'static'全ページを事前レンダリング。SSRは不可(今のプロジェクトはこれ)。
  • output: 'server'全ページがデフォルトでSSR。個別に prerender = true でオプトイン可能。
  • output: 'static' + ページ単位の prerender = false(以前は”hybrid”と呼ばれていたもの)— 混在。大半は静的で、一部がSSR。ただし1ページでもSSRがあれば、アダプタと常駐ランタイムが必須。

SSRページが1枚でもあれば、そのページへのリクエストは毎回ランタイム関数を通ります。ビルドは走りません。

「Astroで1ページだけリビルドできる?」 — ISRの領域

いいえ。Astroのコアには「1ページだけリビルドする」機能はありません。あなたが想像しているのはIncremental Static Regeneration (ISR)で、これはNext.jsが広めたものです。

ISRの仕組み:

  1. astro build 時にページが静的にビルドされ、どこか(CDN、関数キャッシュ)にキャッシュされる。
  2. リクエストが来たらCDNがキャッシュHTMLを即座に配る。
  3. キャッシュがN秒以上古い(あるいは手動で無効化された)場合、そのページ1枚だけバックグラウンドで再生成、新しい版に差し替え、次のリクエストから新しい版が配られる。

ISRはAstro自体の機能ではなく、ホスティングプラットフォームのアダプタの特性:

  • Vercelアダプタ@astrojs/vercel)— アダプタ設定の isr: { expiration: 60 } でISRをサポート。on-demandページも指定可能。あなたが想像しているのに最も近い。
  • Netlifyアダプタ@astrojs/netlify)— on-demand builders(現在は “persistent functions”)がstale-while-revalidateで同じことをやる。
  • Cloudflareアダプタ — 古典的な意味でのISRは無い。Cache API / KVで自作するしかない。
  • Nodeアダプタ — ISRなし。キャッシュ自作。

つまり「コンテンツが変わったら1ページだけリビルドする」は可能ですが、プラットフォームがISRをAstroの上に載せている場合のみ。しかもそこでの「リビルド」は1ページぶんのハンドラの再実行で、サイト全体の astro build ではありません。

Pick Coverシナリオへの意味

あのボタンがデプロイ済みサイトで動くには何が必要か:

選択肢起きることAstro的に
A. 完全static + git-backed CMSボタン → GitHubへコミット → build hook → フル astro build(3-4分)→ CDN更新現状のアーキテクチャ。動くが即時ではない
B. 対象ページをSSR化/guides/[slug]prerender = false にし、coverをDB/KVに入れて毎リクエスト読み込みguideリクエストが毎回関数を走る。遅くインフラも増えるが編集は即時
C. SSR + Vercel/NetlifyのISRguideページはデフォルトstatic、CDNキャッシュ、POSTハンドラがそのslugのキャッシュを無効化、次のリクエストで1ページだけ再生成on-demand無効化を提供しているプラットフォームが必要。Astroコアは直接提供しない
D. クライアント側でdynamic fetchMDXは静的、coverのURLだけ別エンドポイントからページロード時に取得大半は静的。APIエンドポイントのみSSR

コンテンツ中心のAstroサイトで「少しだけdynamicにしたい」とき、多くの人が取るのがD案。SSGでページの殻を保ちつつ、SSRエンドポイントを数個だけ用意して、動的な部分をそこから取る。

じゃあSSRは何に向いているのか

SSRが元を取れる具体例:

  • 認証が必要なページ/account/orders はログインユーザーを見る必要があるので事前ビルド不可能。
  • パーソナライズ — A/Bテスト、ジオベースのコンテンツ、ユーザー別のレコメンド。
  • ロングテールの動的ルート — 数百万商品のEC。全部事前ビルドすると何時間もかかる。上位1000だけビルド、残りはSSR。
  • 検索結果ページ/search?q=foo、組み合わせ無限、事前ビルド不可。
  • プレビューモード — CMSのドラフト、編集者向けにon-demandレンダリング。

数百の商品と週に数回変わるguideという規模なら、SSG + 定期リビルドが正解。SSRは遅い、インフラが増える、メリットが無いで完全にマイナス。

TL;DR

  • SSR = リクエストごとに呼び出される関数。ビルドをトリガーしない。
  • ビルドは事前に1回だけ行われ、静的HTML(SSG)あるいはサーバーバンドル(SSR)を生成する。普通はその両方。
  • 「1ページだけリビルド」はISRで、アダプタ/プラットフォームの機能(Vercel、Netlify persistent functions)、Astroコアの機能ではない。
  • prod環境で100% staticなのは、このあたりの複雑さを抱え込む価値がコンテンツサイトには無いから。Pick Coverは全ての変異をdevモードに閉じ込めることで、そこを完全にバイパスしている。
Takazudo
Takazudo
Takazudo

ah sorry, my bad. I mixed SSR and SSG. so with Astro, it has SSR. but I recognize Astro’s build is standing on the temporary generated DB like thing by… like collection frontmatter of mdx, or JSON, with such way. when we use Astro’s SSR, what happens about that kind of data? It looks like we need full website build even if it is cached on Astro side.

あ、すみません、こちらの勘違いでした。SSRとSSGを混同していた。AstroにはSSRがあるんですよね。で、Astroのビルドは一時的に生成されるDB的なもの、MDXのコレクションのfrontmatterとかJSONとか、そういうものの上に立っていると認識しているんですが、AstroのSSRを使うとき、その手のデータはどうなるんですか? Astro側でキャッシュしているとしても結局フルビルドが必要に見えるんですが。

Claude Code
Claude Code
Claude Code

Your intuition is exactly right, and you’ve spotted the single most important subtlety about Astro SSR: SSR does not give you a live content layer. Let me unpack it.

What the content layer actually is

In Astro 5+ (and this project runs Astro 6), content collections are powered by the Content Layer API. When you run astro dev or astro build, Astro:

  1. Walks your configured sources (glob loaders for src/mdx/**/*.mdx, custom loaders for APIs/DBs, etc.).
  2. Parses each MDX/JSON file — extracts frontmatter, validates against the Zod schema, compiles the body.
  3. Writes the result into a data store — a serialized cache at .astro/data-store.json (plus assets).
  4. Exposes it via astro:contentgetCollection('guides'), getEntry(...), etc.

This “database-like” thing you’re picturing is real. It’s an in-memory indexed store, fed by a build-time process. Queries against it are just object lookups — no filesystem I/O at request time.

What happens to it in SSR mode

Here’s the key fact: the content layer still runs at astro build time. SSR does not change that. What differs is what the build produces:

ModeWhat build producesWhere content data lives
SSG (output: 'static')Static HTML for every pageConsumed during build, then discarded — HTML is the artifact
SSR (output: 'server')A JS server bundle exporting route handlersSerialized into the server bundle itself and shipped to the runtime

When your SSR page handler calls getCollection('guides') at request time, it’s not reading from src/mdx/guides/*.mdx — those files don’t exist on the deployed function. It’s reading from the snapshot that got baked into the server bundle at build time. In-memory object lookup. Fast. Static.

So “SSR a guide page” gives you:

  • Dynamic params, cookies, headers, auth, query strings, DB reads — yes, these are per-request.
  • Fresh content from the MDX file — no, still a build-time snapshot.

You were right: updating an MDX file in production requires a rebuild, even in SSR mode. SSR buys you live code, not live content.

Why dev mode feels different

In astro dev, the content layer has a file watcher. Edit an MDX file → Astro incrementally re-runs the loader for that one entry → updates the in-memory store → Vite HMR pushes the new data to the browser. That’s why the Pick Cover button “just works” — the file write triggers the watcher, which re-indexes, which HMRs.

None of that machinery exists in production. The watcher is a dev-time Vite plugin; the in-memory store in prod is frozen at build time.

”Do we need a full site build for one MDX change?”

In Astro core: yes. astro build is all-or-nothing. There is no astro build --only src/mdx/guides/foo.mdx. It re-runs the content layer for the whole site, then renders every page (in SSG) or bundles the server (in SSR).

Astro does have some build-time caching — the content layer uses the data store as a cache, so entries whose source file didn’t change can be skipped on the parse step. But the subsequent bundling + rendering steps still touch the whole site.

If you want per-page invalidation in production, you’re in ISR / on-demand-revalidation territory, and that’s a platform concern, not an Astro concern:

  • Vercel adapter: set isr: { expiration: 60 } or use on-demand revalidation webhooks — tells Vercel to regenerate just /guides/foo/ on the next request after invalidation.
  • Netlify adapter: on-demand builders / persistent functions — same idea, different API.
  • Cloudflare / Node adapters: no native ISR. You’d roll your own cache with KV/Cache API.

Even then — and this is the subtlety — those platforms are re-running your SSR handler, which reads from the bundled content snapshot from the last full build. They don’t re-execute the content layer per page. So ISR regenerates HTML, but the source data it reads is still from the last astro build.

To actually get “change MDX → page updates, no full rebuild,” you need to stop using the content layer as your source of truth and instead pull content from somewhere live:

// Dynamic guide route, SSR mode
export const prerender = false;

export async function GET({ params }) {
  const mdx = await fetch(`https://cms.example.com/api/guides/${params.slug}`);
  // ...render on the fly
}

That’s the headless-CMS pattern. The content layer is bypassed entirely; getCollection() isn’t involved. It’s a different architecture, with different trade-offs (DB latency per request, no Zod validation at edit time, no git history on content, etc.).

The summary you’re probably reaching for

  • Astro’s content collections are a build-time indexer. The result is a data store.
  • SSG bakes that store into HTML. SSR bakes it into the server bundle. Either way, the store is a snapshot.
  • SSR makes your route handlers dynamic. It does not make your content collections dynamic.
  • “Change MDX → see it live in prod” → no Astro-native solution. Options: rebuild, ISR at the adapter level, or move content out of collections into a live source.
  • The Pick Cover trick works precisely because it only exists in dev, where the watcher + HMR chain actually connects filesystem changes back to the UI.

This is why dev-only tools that rewrite source files are such a sweet spot for content-heavy Astro sites: you get all the SSG speed + CDN simplicity in prod, and you get live editing feel in dev, without ever needing to justify an SSR runtime.

あなたの直感は完全に正しくて、AstroのSSRの一番重要な細部を捉えています。SSRはライブなcontent layerをくれません。順番に説明します。

content layerとは何か

Astro 5+(このプロジェクトはAstro 6)のcontent collectionsはContent Layer APIに支えられています。astro devastro build を走らせると、Astroは以下を行います:

  1. 設定されたsource(src/mdx/**/*.mdx のglobローダーや、API/DBのカスタムローダーなど)を辿る。
  2. 各MDX/JSONをパース — frontmatterを抽出し、Zodスキーマで検証し、bodyをコンパイル。
  3. 結果をデータストアに書き込む — .astro/data-store.json にシリアライズされたキャッシュ(加えてアセット類)。
  4. astro:content 経由で公開 → getCollection('guides')getEntry(...) など。

あなたが想像している「DB的なもの」は実在します。メモリ上にインデックス化されたストアで、ビルド時のプロセスが投入する。クエリはオブジェクトのlookupで、リクエスト時にファイルシステムを読みに行くことはありません。

SSRモードでこれに何が起きるか

決定的な事実: content layerは依然として astro build の時点で実行される。SSRはこれを変えない。変わるのはビルドの成果物の中身です:

モードビルドが作るものcontent dataの置き場
SSGoutput: 'static'全ページの静的HTMLビルド中に消費されてあとは捨てられる — HTMLが成果物
SSRoutput: 'server'ルートハンドラをexportするJSのサーバーバンドルサーバーバンドルの中にシリアライズされて同梱、ランタイムへ出荷

SSRのページハンドラがリクエスト時に getCollection('guides') を呼ぶと、それは src/mdx/guides/*.mdx を読んでいるのではない(デプロイ先の関数にはそのファイルが存在しない)。読んでいるのはビルド時にサーバーバンドルに焼き込まれたスナップショット。メモリのオブジェクトlookup。速い。そして静的。

なのでguideページをSSR化した場合:

  • 動的なパラメータ、cookie、ヘッダ、認証、クエリ文字列、DB読み取り — はい、これらはリクエストごと。
  • MDXファイルのフレッシュな内容 — いいえ、ビルド時のスナップショット。

あなたが指摘した通り、productionでMDXを更新するにはSSRモードでも再ビルドが必要。SSRが手に入るのはライブな コード であって、ライブな コンテンツ ではない。

なぜdevモードだと違って見えるのか

astro dev ではcontent layerにファイルウォッチャが付いています。MDXを編集 → Astroがそのエントリだけに対してローダーをインクリメンタルに再実行 → インメモリストアを更新 → Vite HMRがブラウザに新しいデータをpush。Pick Coverが「普通に動いている」のはこのためで、ファイル書き込みがウォッチャを起こし、再インデックスを走らせ、HMRが駆動する。

productionにはそのメカニズムが何一つ無い。ウォッチャはdev専用のVite pluginで、prodのインメモリストアはビルド時に凍結されている。

「1つのMDX変更のためにサイト全体のビルドが必要?」

Astroコアでははいastro build はall-or-nothing。astro build --only src/mdx/guides/foo.mdx はありません。サイト全体のcontent layerを再実行し、全ページをレンダリング(SSG)あるいはサーバーをバンドル(SSR)します。

ビルド時のキャッシュはあります — content layerはデータストアをキャッシュとして扱うので、ソースファイルが変わっていないエントリはパース段階をスキップできる。しかしその後のbundle/レンダリング段階はサイト全体を触ります。

prodでページ単位の無効化が欲しければ、ISR/on-demand-revalidationの領域で、Astroではなくプラットフォームの責任:

  • Vercelアダプタ: isr: { expiration: 60 } あるいはon-demand revalidation webhook。
  • Netlifyアダプタ: on-demand builders / persistent functions。
  • Cloudflare / Nodeアダプタ: ネイティブなISRは無し。KV / Cache APIで自作。

しかも — ここが肝心 — それらのプラットフォームはSSRハンドラを再実行しているだけであって、そのハンドラは最後のフルビルド時に焼き込んだcontentスナップショットを読んでいる。ページごとにcontent layerを再実行しているわけではない。ISRはHTMLを再生成するが、ソースデータは依然として前回の astro build 時のもの。

「MDXを変えたら再ビルドなしにページが更新される」を実現するには、content layerをsource of truthとして使うのをやめて、コンテンツをライブなソースから取るしかない:

// 動的guideルート、SSRモード
export const prerender = false;

export async function GET({ params }) {
  const mdx = await fetch(`https://cms.example.com/api/guides/${params.slug}`);
  // ...その場でレンダリング
}

headless-CMSのパターン。content layerは完全にバイパス、getCollection() は関係なし。別のアーキテクチャで、トレードオフも変わる(リクエストごとのDBレイテンシ、編集時点のZod検証なし、contentのgit履歴なし等)。

結局言いたかったこと

  • Astroのcontent collectionsはビルド時のインデクサ。結果はデータストア。
  • SSGはそのストアをHTMLに、SSRはサーバーバンドルに焼く。どちらにせよスナップショット
  • SSRが動的にするのは ルートハンドラ。content collectionsを動的にはしない。
  • 「MDX変更 → prodで即反映」にAstroネイティブの解は無い。選択肢: 再ビルド、アダプタレベルでのISR、あるいはcontentをcollectionsの外に出してライブソースから取る。
  • Pick Coverトリックが成立するのはdev専用だから。ウォッチャ + HMRがファイルシステムの変更をUIに繋いでくれる。

ソースファイルを書き換えるdev専用ツールは、コンテンツ中心のAstroサイトにとってスイートスポット。prodではSSGの速さとCDNのシンプルさを全て得ながら、devではライブ編集感も得られ、SSRランタイムの正当化を一切しなくていい。

Takazudo
Takazudo
Takazudo

all right, so Astro SSR uses build timing snapshot data… but if so, what is that SSR for? it can use workers or netlify function result in a page — inside the astro template?

わかりました。つまりAstroのSSRはビルド時のスナップショットデータを使うと。だったらそのSSRは何のためにあるんですか? ページの中でworkersやNetlify functionの結果を使える、つまりastroテンプレートの中で?

Claude Code
Claude Code
Claude Code

Yes — that is exactly what SSR is for. You’ve just reached the real answer on your own. Let me sharpen the mental model with concrete examples.

The one-sentence version

SSR makes the .astro page’s frontmatter code run per-request, which means you can await fetch(...) at request time — from a Worker, a Netlify Function, a database, a third-party API, whatever — and render the response directly into the template.

The minimal example

---
// src/pages/inventory.astro
export const prerender = false; // SSR this page

// This runs on every request, on the server (Worker / Function / Node)
const res = await fetch('https://api.example.com/stock');
const stock = await res.json();
---

<Layout>
  <h1>現在の在庫</h1>
  <ul>
    {stock.items.map((item) => <li>{item.name}{item.count}個</li>)}
  </ul>
</Layout>

At request time, the server runs the --- block, the fetch hits a live API, and the HTML is rendered with fresh data. No build involved. Deploy that to Cloudflare via @astrojs/cloudflare and the whole Astro app is a Worker — the .astro page runs inside the Worker runtime on every request.

The killer pattern: baked + live in the same page

This is where SSR actually earns its keep. You can mix content-layer data (static, baked at build) with live API data (fetched per request) in a single template:

---
// src/pages/products/[slug].astro
import { getEntry } from 'astro:content';
export const prerender = false;

const { slug } = Astro.params;

// Static: from the build-time snapshot (your product master data, brand info, specs)
const product = await getEntry('products', slug);

// Live: from a runtime API (stock, price-in-JPY-today, reservation count)
const [stockRes, priceRes] = await Promise.all([
  fetch(`https://inventory.example.com/stock/${slug}`),
  fetch(`https://rates.example.com/jpy/${product.data.priceUSD}`),
]);
const stock = await stockRes.json();
const price = await priceRes.json();
---

<Layout title={product.data.title}>
  <h1>{product.data.title}</h1>
  <p>{product.data.description}</p>
  {/* static from MDX */}
  <p>本日の価格: ¥{price.jpy.toLocaleString()}</p>
  {/* live */}
  <p>在庫: {stock.count > 0 ? `${stock.count}点` : '売り切れ'}</p>
  {/* live */}

  <product.Content />
  {/* rendered MDX body — still baked */}
</Layout>

This is the thing SSR enables that SSG can’t do. The page has both halves: stable content from MDX that doesn’t change between deploys, and volatile data that would be stale if baked. One HTML response, rendered per request, combining both.

Real examples where this pays off

Use caseStatic (content layer)Live (SSR fetch)
Product pageSpec, description, photos, brandStock count, live FX rate, “N people viewing”
Blog postArticle body, frontmatterComment count, view count, related-posts from ML service
Event pageEvent description, venue, date”Spots remaining”, waitlist status
DashboardPage chrome, nav, static help textUser-specific data from DB
Guide pageGuide content from MDXPer-user progress, “you’ve completed N/9 episodes”

The template is the composition layer. That’s SSR’s job — not to make content live (the content layer is still frozen), but to let you mix live request-time data into a page that also uses baked data, inside one rendering pass.

The alternative: keep the page static, hydrate an island

If the live data isn’t needed for SEO / first-paint, you have a second option — keep the page 100% static and put a client-side island on just the volatile piece:

---
// src/pages/products/[slug].astro  (prerendered, no SSR)
import { getEntry } from 'astro:content';
import StockBadge from '../../components/stock-badge.tsx';

const { slug } = Astro.params;
const product = await getEntry('products', slug);
---

<Layout>
  <h1>{product.data.title}</h1>
  <StockBadge client:load slug={slug} />
  {/* React/Preact island, fetches on mount */}
</Layout>
// src/components/stock-badge.tsx
export default function StockBadge({ slug }: { slug: string }) {
  const [stock, setStock] = useState<number | null>(null);
  useEffect(() => {
    fetch(`/api/stock/${slug}`)
      .then((r) => r.json())
      .then((d) => setStock(d.count));
  }, [slug]);
  return <p>在庫: {stock ?? "..."}</p>;
}

Now the whole page is static HTML on the CDN. Only the stock badge hits the network — from the browser, after hydration. /api/stock/:slug can be a Netlify Function / Cloudflare Worker / whatever.

This is actually how the site is built today. Static Astro + a handful of Netlify Functions (search, etc.) + client-side fetches from islands when needed. No SSR at all. The dev-api Pick Cover thing is a separate hack that only exists in dev.

When to pick which

SituationAnswer
Data is static-enough to bakeSSG. Don’t overthink it.
Data is dynamic but only matters for logged-in users, doesn’t affect SEOStatic page + client-side fetch from an island. Cheapest, fastest CDN serve, no SSR infra.
Data is dynamic AND must be in the HTML (SEO, og:image, first-paint)SSR the page. Fetch at request time, render into template.
Page has too many route combinations to prerender (millions of products)SSR. Or SSG the top-N + SSR the long tail.
Auth / session state must gate renderingSSR. Cookies are server-side.
You’re tempted to “SSR because it feels modern”Don’t. Stay static, use islands.

The Worker / Netlify-Function question specifically

Three distinct setups people confuse:

  1. Astro SSR deployed as a Worker@astrojs/cloudflare. The whole Astro app is compiled into a Worker. .astro frontmatter is the Worker code. There’s no “calling a Worker” — the page is the Worker.
  2. Static Astro + separate Workers — Astro is SSG, deployed to CDN. Separate Workers / Netlify Functions exist at /api/* for dynamic bits. Pages call them via client-side fetch (from islands) or build-time fetch (if the data can be baked).
  3. Astro SSR calling OTHER Workers — Astro page runs on Cloudflare/Netlify, and its frontmatter fetches a second Worker in a different service. Classic microservice pattern. Works fine, just adds a network hop.

The current project is squarely #2. A hypothetical “live product stock” feature could go either #2 (client-side from an island) or switch to #3 (SSR the product page, fetch stock during render). The first is simpler; the second gives you SEO-visible stock numbers.

So your framing — “it can use Workers or Netlify Function result in a page inside the Astro template” — is precisely correct. That’s the core of what SSR is for. The content layer stays frozen, the template becomes dynamic, and live data shows up by await fetching it at render time.

はい — まさにそれがSSRの目的です。答えに自力で辿り着きましたね。具体例でメンタルモデルを鋭くしましょう。

一文で言うと

SSRは .astro ページのfrontmatterコードをリクエストごとに実行させる機能です。つまりリクエスト時に await fetch(...) を呼べる — Worker、Netlify Function、DB、サードパーティAPI、何からでも — そしてその結果をそのままテンプレートにレンダリングできる。

最小例

---
// src/pages/inventory.astro
export const prerender = false; // このページをSSRに

// リクエストごとにサーバー (Worker / Function / Node) で実行される
const res = await fetch('https://api.example.com/stock');
const stock = await res.json();
---

<Layout>
  <h1>現在の在庫</h1>
  <ul>
    {stock.items.map((item) => <li>{item.name}{item.count}個</li>)}
  </ul>
</Layout>

リクエスト時にサーバーが --- ブロックを実行、fetch がライブAPIを叩き、HTMLが新鮮なデータでレンダリングされて返る。ビルド不要。@astrojs/cloudflare 経由でCloudflareにデプロイすると、Astroアプリ全体が 一つのWorker になり、.astro ページが毎リクエストWorkerランタイム内で実行される。

真のキラーパターン: 焼き込み + ライブを同じページに混ぜる

ここがSSRの真価。content-layerのデータ(ビルド時に焼き込んだ静的なもの)とライブAPIデータ(リクエスト時にfetchするもの)を、一つのテンプレートに混ぜられる:

---
// src/pages/products/[slug].astro
import { getEntry } from 'astro:content';
export const prerender = false;

const { slug } = Astro.params;

// Static: ビルド時のスナップショットから(製品マスタ、ブランド、スペック)
const product = await getEntry('products', slug);

// Live: ランタイムAPIから(在庫、今日のJPYレート、予約数)
const [stockRes, priceRes] = await Promise.all([
  fetch(`https://inventory.example.com/stock/${slug}`),
  fetch(`https://rates.example.com/jpy/${product.data.priceUSD}`),
]);
const stock = await stockRes.json();
const price = await priceRes.json();
---

<Layout title={product.data.title}>
  <h1>{product.data.title}</h1>
  <p>{product.data.description}</p>
  {/* MDXから、静的 */}
  <p>本日の価格: ¥{price.jpy.toLocaleString()}</p>
  {/* ライブ */}
  <p>在庫: {stock.count > 0 ? `${stock.count}点` : '売り切れ'}</p>
  {/* ライブ */}

  <product.Content />
  {/* レンダリングされたMDX本文 — 焼き込み済み */}
</Layout>

SSGには出来なくてSSRにだけ出来ることがこれ。ページには2つの半分がある: デプロイ間に変わらない安定コンテンツはMDXから、焼いたら古くなる揮発性データはライブから。一つのHTMLレスポンスをリクエストごとにレンダリングし、両方を合成する。

実際のユースケース

ケース静的(content layer)ライブ(SSR fetch)
製品ページスペック、説明、写真、ブランド在庫数、為替レート、「N人が閲覧中」
ブログ記事本文、frontmatterコメント数、閲覧数、関連記事(ML)
イベントページ説明、会場、日時残席、ウェイトリスト状況
ダッシュボード外枠、ナビ、静的ヘルプユーザー固有のDBデータ
GuideページMDXの本文ユーザーの進捗、「9話中N話完了」

テンプレートはコンポジション層。これがSSRの仕事 — contentをライブにすることではなく(content layerは依然として凍結)、一つのレンダリングパスの中で、リクエスト時のライブデータと焼き込み済みデータを混ぜること。

代替案: ページを静的に保ち、islandをhydrateする

ライブデータがSEOや初回描画に不要なら、もう一つの選択肢。ページは100%静的に保ち、揮発部分だけにクライアントサイドislandを置く:

---
// src/pages/products/[slug].astro(事前レンダリング、SSRなし)
import { getEntry } from 'astro:content';
import StockBadge from '../../components/stock-badge.tsx';

const { slug } = Astro.params;
const product = await getEntry('products', slug);
---

<Layout>
  <h1>{product.data.title}</h1>
  <StockBadge client:load slug={slug} />
  {/* React/Preact island、マウント時にfetch */}
</Layout>
// src/components/stock-badge.tsx
export default function StockBadge({ slug }: { slug: string }) {
  const [stock, setStock] = useState<number | null>(null);
  useEffect(() => {
    fetch(`/api/stock/${slug}`)
      .then((r) => r.json())
      .then((d) => setStock(d.count));
  }, [slug]);
  return <p>在庫: {stock ?? "..."}</p>;
}

これでページ全体はCDN上の静的HTML。在庫バッジだけがブラウザからネットワークを叩く(hydration後)。/api/stock/:slug はNetlify Function / Cloudflare Worker / 何でもいい。

実際これが今のサイトの作り。 Astroはstatic、Netlify Functions数個(検索等)、必要に応じてislandからクライアント側fetch、SSRは無し。dev-api Pick Coverはそれとは別の、dev専用ハック。

どっちを選ぶか

状況答え
データが焼いて十分SSG。考えすぎない
データは動的だがログインユーザー向けだけ、SEOに影響しない静的ページ + islandがfetch。最安、CDNで最速、SSRインフラ無し
データは動的 AND HTMLの中にいる必要がある(SEO、og:image、初回描画)SSRでページをレンダリング。fetchをリクエスト時に行い、テンプレートに差し込む
ルートの組み合わせが多すぎて事前ビルドできない(数百万の商品)SSR。あるいはトップN件だけSSG + 残りはSSR
認証/セッションが必要SSR。cookieはサーバー側
「なんとなくモダンだからSSR」やめなさい。staticのままでislands

Worker / Netlify Functionの話を整理

混同されがちな3つの別物:

  1. AstroのSSRをWorkerとしてデプロイ@astrojs/cloudflare。AstroアプリまるごとがWorkerにコンパイルされる。.astro frontmatterがWorkerのコード。「Workerを呼ぶ」のではなく、ページ がWorkerそのもの
  2. 静的Astro + 別のWorker — AstroはSSG、CDNにデプロイ。別途Worker/Netlify Functionsが /api/* にあり、動的部分を担当。ページはそれらをクライアント側(islandから)ないしビルド時に fetch する。
  3. AstroのSSRが別のWorkerを呼ぶ — Astroページ自体はCloudflare/Netlifyで動き、frontmatterが別のWorkerへ fetch。マイクロサービスパターン。動作するがネットワークホップが増える。

今のプロジェクトは完全に#2。仮に「ライブ在庫」機能を足すなら #2(クライアント側island)か #3(productページSSR化、レンダー中にfetch)のどちらか。前者の方が簡単、後者だとSEO的に在庫数が拾える。

あなたの言い方 — 「workersやNetlify functionの結果をページの中、astroテンプレートの中で使える」 — まさにそれがSSRの核。content layerは凍結のまま、テンプレートが動的になり、ライブデータはレンダー時の await fetch で現れる。

Takazudo
Takazudo
Takazudo

hum, so SSR is literally… SSR? there is a 2 patterns?

A: real SSR. the request makes the HTML in serverside. by workers or Netlify functions like thing B: it’s called SSR, but it’s SSG + hydration on time API request -> update the page

we can choose both with Astro? Or it is called SSR, but in fact, is it B?

うーん、つまりSSRは文字通り……SSR? パターンが2つある?

A: 本当のSSR。リクエストが来たらサーバーサイドでHTMLを作る。WorkersやNetlify functions的なやつで。 B: SSRと呼ばれているけど、実態はSSG + ハイドレーション時のAPIリクエスト → ページ更新。

Astroだと両方選べるんですか? それとも呼び名はSSRだけど実態はBなんですか?

Claude Code
Claude Code
Claude Code

Great sharpening of the question. Clean answer:

Astro SSR is genuinely A. Real SSR.

When Astro says “SSR,” it means the .astro frontmatter literally runs on a server (Node / Worker / Function) per request, and the HTML that leaves the server already contains the fresh data. Pattern A. No ambiguity.

Pattern B — “static HTML + browser-side fetch that fills in data after hydration” — exists and is very common in Astro projects, but it is not called SSR. Astro calls it islands (or “client-side data fetching in an island”). The server at that point is doing nothing per-request; it’s still serving the same bytes from the CDN to everyone, and all the dynamism lives in the browser after JS boots.

So: two different things, two different names, don’t conflate them.

The four combinations, with names

Page modeRuntime data fetch locationAstro nameInfra
prerender = truenoneSSG (static)CDN only
prerender = truebrowser (inside an island)SSG + client islandsCDN + some API endpoint somewhere
prerender = falseserver (inside .astro frontmatter)SSRSSR adapter (Node/Worker/Function)
prerender = falseboth server AND browser islandsSSR + client islandsSSR adapter + island API endpoints

Rows 2 and 3 both give you “live data on the page,” but through completely different mechanisms:

  • Row 2 — the HTML is the same cached bytes for everyone. The user’s browser downloads it, runs JS, the island mounts, fetch() hits an API, the DOM updates. Server sees nothing per-request. First paint is stale, then updates after JS. Bad for SEO on the dynamic piece. Cheap.
  • Row 3 — every request hits the SSR runtime. It runs the frontmatter, calls fetch server-side, builds the HTML with the fresh data baked in, returns it. User sees fresh data at first paint. Good for SEO. More expensive, slower TTFB, needs always-on infra.

Your question — “is Astro’s SSR secretly B?” — no. It’s A. If you want B, you write a static page with an island.

Both are available. You pick per-page.

You can mix them freely in one project:

  • src/pages/index.astroprerender = true, fully static.
  • src/pages/products/[slug].astroprerender = true, but embeds a <StockBadge client:load /> island that fetches stock in the browser. (Pattern B.)
  • src/pages/dashboard.astroprerender = false, SSR, reads cookies, calls DB in frontmatter, returns user-specific HTML. (Pattern A.)
  • src/pages/search.astroprerender = false, SSR, reads ?q=..., calls search service, renders results. (Pattern A.)

Per-page decision. Astro’s build produces a static output for the prerendered pages and a server bundle for the rest. You need an SSR adapter installed, but only the non-prerendered pages actually use it.

The one complication: “server islands” (Astro 5+)

Worth knowing it exists, since it blurs the line.

Astro 5 added a feature called server islands. You mark a component with server:defer and the rest of the page stays SSG (on the CDN), but at request time the runtime sends a follow-up request to the SSR runtime to render just that one island, and the result is stitched into the page as it streams. It looks like:

---
// page is still prerendered — ships from the CDN
---

<Layout>
  <h1>OXI ONE MKII</h1>
  <ProductDescription />
  {/* static */}
  <StockPanel server:defer>
    {/* rendered by the SSR runtime per request */}
    <p slot="fallback">読み込み中...</p>
  </StockPanel>
</Layout>

This is a genuinely third thing: the page itself is SSG (row 1), but it has holes that are filled in by real SSR (row 3) at request time, without going through a client-side fetch from a browser island. It’s somewhere between B and A — the dynamic data is server-rendered (A’s strength) without paying SSR cost for the whole page (B’s strength). Think of it as “partial SSR,” or ESI/edge-side-includes for Astro.

Good for: dynamic fragments where you want server-rendered HTML without sacrificing the CDN cache for the shell. Trade-off: requires an SSR runtime for the island even though the shell is static, and the island shows a fallback until the runtime responds.

TL;DR

  • Astro SSR is real SSR. Server generates HTML per request.
  • The “static page + browser fetch” thing is islands, not SSR. Different mechanism, different name.
  • You pick per-page which mode a page runs in.
  • Server islands are a newer third option that gives you SSR-rendered fragments inside an otherwise-static page — worth knowing but not what “SSR” means in the plain sense.

So when docs say “SSR,” read it as “the server runs your template on every request.” When you see “islands” or client:* directives, that’s browser-side hydration, which is the B pattern you were naming.

質問をうまく鋭くしてくれました。はっきり答えると:

AstroのSSRは正真正銘A。本物のSSR。

Astroが「SSR」と言うとき、それは.astro frontmatterが文字通りサーバー(Node / Worker / Function)でリクエストごとに実行され、サーバーから出ていく時点のHTMLには既にフレッシュなデータが入っていることを意味します。パターンA。曖昧さゼロ。

パターンB — 「静的HTML + ブラウザ側 fetch でハイドレーション後にデータを埋める」 — はAstroプロジェクトによく出てくる構成ですが、SSRとは呼ばれません。Astroではこれをislands(あるいは「islandの中でクライアント側データフェッチ」)と呼びます。サーバーは何もリクエストごとに処理していない。CDNが同じバイトを全員に配っているだけで、ダイナミズムはJSが起動した後のブラウザに存在する。

なので: 別物、別名、混同しない。

4パターンと名前

ページモードランタイムデータfetchの場所Astroの呼び名インフラ
prerender = trueなしSSG(static)CDNのみ
prerender = trueブラウザ(islandの中)SSG + client islandsCDN + どこかにAPI
prerender = falseサーバー(.astro frontmatter)SSRSSRアダプタ(Node/Worker/Function)
prerender = falseサーバーとブラウザ両方SSR + client islandsSSRアダプタ + islandのAPI

行2と行3はどちらも「ページ上にライブデータ」を実現しますが、仕組みは全く違います:

  • 行2 — HTMLは全員同じキャッシュバイト。ブラウザがダウンロード、JS実行、islandがマウント、fetch() がAPIを叩き、DOMを更新。サーバーはリクエストごとに何も見ない。 初回描画は古い、JS後に更新。動的部分のSEOは弱い。安い。
  • 行3 — リクエストのたびにSSRランタイムに当たる。frontmatterを実行、サーバー側で fetch、フレッシュデータ込みのHTMLを組んで返す。ユーザーは初回描画の時点で既にフレッシュデータを見る。 SEO的に強い。コストは高い、TTFBは遅い、常駐インフラが必要。

「AstroのSSRは秘密裏にBなんじゃないの?」 — 違います、Aです。Bが欲しければ静的ページ + island構成を書きます。

両方使える。ページ単位で選ぶ。

1プロジェクトで自由に混ぜられます:

  • src/pages/index.astroprerender = true、完全にstatic。
  • src/pages/products/[slug].astroprerender = true だが <StockBadge client:load /> islandでブラウザから在庫fetch(パターンB)。
  • src/pages/dashboard.astroprerender = false、SSR、cookie読み取り、frontmatterでDB呼び出し、ユーザー固有HTMLを返す(パターンA)。
  • src/pages/search.astroprerender = false、SSR、?q=... を読み、検索サービスを叩いて結果描画(パターンA)。

ページごとの判断。Astroのビルドはprerenderedなページ用にstatic出力を、残り用にサーバーバンドルを作る。SSRアダプタは必要だが、使うのは非prerenderedページだけ。

1つの複雑性: “server islands”(Astro 5+)

境界線を曖昧にするので知っておく価値あり。

Astro 5でserver islandsという機能が追加されました。コンポーネントに server:defer を付けると、ページの残りはSSG(CDN)のままで、リクエスト時にランタイムがそのislandだけレンダリングするfollow-upリクエストを送り、結果をページに貼り付ける:

---
// ページ自体はprerendered — CDNから配る
---

<Layout>
  <h1>OXI ONE MKII</h1>
  <ProductDescription />
  {/* 静的 */}
  <StockPanel server:defer>
    {/* リクエストごとにSSRランタイムがレンダリング */}
    <p slot="fallback">読み込み中...</p>
  </StockPanel>
</Layout>

これは正真正銘の第3の何か。ページ自体はSSG(行1)だが、穴があって本物のSSR(行3)がリクエスト時に埋める。ブラウザ側 fetch を経由しない。BとAの間にあるようなもの — Aの強み(サーバーレンダリングHTML)を得ながら、Bの強み(ページ全体SSRを回避)も取る。「部分的SSR」とか、Astro版のESI(edge-side includes)と思えば良い。

向いている: CDNキャッシュは殻に保ちたいが、一部の動的フラグメントはサーバー側で描画したい場面。 トレードオフ: 殻はstaticでもislandのためにSSRランタイムが必要、かつislandはランタイムが返事するまでfallbackが見える。

TL;DR

  • AstroのSSRは本物のSSR。サーバーがリクエストごとにHTMLを生成する。
  • 「静的ページ + ブラウザ fetch」はislandsであってSSRではない。別物、別名。
  • ページ単位で選べる。
  • Server islandsはそれらとは別の新しい選択肢。staticなページの中にSSR描画のフラグメントを埋める。知っておく価値はあるが普通の意味の「SSR」ではない。

ドキュメントで「SSR」と書かれていたら「サーバーがテンプレートをリクエストごとに実行する」と読む。islandsclient:* ディレクティブを見たらそれはブラウザハイドレーションで、あなたが名付けたBのほう。

Takazudo
Takazudo
Takazudo

about server:defer, is that request come as one request? so… the body part first, then next part at the later side, and workers like system handles it? but you said <p slot="fallback">読み込み中...</p> isn’t it JavaScript hydration?

server:defer について、リクエストは1つで来るんですか? つまりbody部分が先に、後の部分が後から来て、workers的なシステムがハンドリングする? でもさっき言ってた <p slot="fallback">読み込み中...</p> って、それってJavaScriptハイドレーションじゃないんですか?

Claude Code
Claude Code
Claude Code

Sharp catch. You’ve spotted the thing frameworks love to be vague about. Let me be exact.

It’s two requests, not one. And yes, JS does the swap.

The mechanism is:

  1. Request 1: GET /guides/foo/ → CDN

    Returns the prerendered shell. This HTML contains:

    • Everything that’s static (header, nav, body, footer)
    • The <p slot="fallback">読み込み中...</p> as literal HTML where the island will go
    • A tiny inline <script> that knows how to fetch the island and splice it in
  2. Request 2: GET /_server-islands/StockPanel?p=<encrypted-props> → SSR runtime

    Initiated by that inline script after the page loads. Hits the Astro SSR runtime (the Worker / Netlify Function / Node process). The runtime renders just the <StockPanel> component server-side and returns its HTML fragment.

  3. DOM swap

    The inline script receives the HTML fragment and replaces the fallback node with it.

So your instinct is right: the fallback is real, the swap is JS-driven, and there are two HTTP requests. The “server” in “server islands” refers to who renders the island’s HTML (the SSR runtime, not the browser), not how the island arrives in the DOM (JS fetch + innerHTML-ish swap).

But it’s not “hydration” in the strict sense

Here’s where the precision matters — because you’re conflating two things that usually go together but don’t have to:

  • Hydration = the browser downloads a framework runtime (React / Preact / Svelte / Vue), attaches it to server-rendered HTML, and takes over rendering as a live component tree. Event handlers get wired up. State becomes reactive. The component is now “alive” in the browser.
  • DOM insertion = JS receives HTML from somewhere and puts it in the page. No framework takes over. It’s just HTML.

Server islands do DOM insertion, not hydration. The swapped-in HTML is inert — no React/Preact runtime is loaded for it, no state, no event handlers beyond whatever plain <a> / <form> / <button onclick=""> the HTML itself contains. If you want the island to be interactive after it lands, you’d nest a client:load island inside the server island and pay hydration cost for that inner part only.

Contrast the two side-by-side:

<StockPanel client:load /> (browser island)<StockPanel server:defer /> (server island)
HTML requests1 (CDN only)2 (CDN + SSR runtime)
Who renders the island’s HTMLBrowser, using the component framework (React/Preact)SSR runtime, using Astro’s server renderer
Framework runtime shipped to clientYes (React/Preact JS bundle)No, unless the island nests a client island
Data fetched…in the browser, from useEffect or similaron the server, in the component’s frontmatter
Fallback while loadingwhatever you render as initial stateliteral <slot name="fallback"> HTML from the prerendered page
SEO sees the dynamic dataNo (browser fetches after load)No (CDN cache has the fallback, not the island) — same as client island for first-paint SEO
CDN cachability of shellYesYes — the whole point of this mode
Needs SSR adapterNoYes — the /_server-islands/* route is SSR

The last two rows are why server islands exist. You want:

  • The shell to sit on the CDN (fast, cheap, cached for everyone),
  • The dynamic piece to be server-rendered (so it doesn’t need a React bundle in the client just to show a number),
  • without SSR-ing the whole page (which would kill CDN caching for the shell).

Server islands hit all three. The cost is: you ship an SSR runtime somewhere to serve the /_server-islands/* endpoint, and the user sees “読み込み中…” for one network round-trip before the real content lands.

Why not a single streaming response?

You might reasonably ask: “Why two separate requests? Couldn’t the server stream the shell first, then the island content second, in a single response?” Some frameworks do this (React Server Components with streaming, Next.js App Router’s streaming SSR, etc.). Astro deliberately chose two separate requests, because:

  • If the shell and island came in one response, the CDN would have to cache or bypass the whole thing as a unit. Caching the shell while keeping the island fresh is impossible with a single response.
  • Two requests let the shell be cached for months at the edge, while the island request goes straight to the SSR runtime every time (or is cached on its own shorter TTL).
  • It also keeps the shell renderable without needing an SSR runtime at all — the shell is 100% static build output. If the SSR runtime is down, the shell still loads (with the fallback visible).

It’s an architectural trade-off favoring independent cachability over single-round-trip latency. Makes sense for content sites that want CDN wins.

So: is server:defer “real SSR” or “JS hydration”?

It’s genuinely a hybrid and your discomfort with the categories is warranted:

  • The island’s HTML is produced by real SSR (row 3 from the table last message — server-side rendering per request).
  • The island’s arrival in the page is JS-driven (row 2’s mechanism — client-side fetch + DOM mutation).

Neither “A” nor “B” from your previous question cleanly. It’s “A’s output glued in via B’s plumbing.” That’s why I called it a third thing — it doesn’t fit the same SSG-vs-SSR binary, because it’s SSG for the shell + SSR for a fragment + client-side orchestration to combine them.

If you want a cleaner framing: server islands are ESI (edge-side includes) for Astro, implemented in the browser instead of at the edge. The shell references a sub-fragment; something splices the sub-fragment into the shell; the user sees one composed page. Classic ESI does it at the CDN layer (Varnish, Akamai, Fastly). Astro does it in the client because CDN-level ESI isn’t universally available and doing it in the client works on any static host.

One practical caveat

Because the island arrives after the initial HTML and the swap happens in JS:

  • Users with JS disabled see the fallback forever. This is the same caveat as client islands — no-JS users are second-class citizens for the dynamic piece.
  • Layout shift is possible when the fallback’s size differs from the real island. Reserve space with CSS.
  • The island can’t be in <head> (or at least, anything it tries to put in <head> is awkward), since it’s being spliced into the body after load. Meta tags / og:image must come from the prerendered shell.

For SEO-critical dynamic data that must appear at first paint, server islands are the wrong tool — you want full SSR of the page (row 3), not server islands. Server islands are for dynamic data where “loads half a second later” is fine.

鋭いキャッチです。フレームワークが曖昧にしがちなところを突いていますね。正確に言います。

リクエストは1つではなく2つ。そしてJSがスワップを行う。

仕組みはこうです:

  1. リクエスト1: GET /guides/foo/ → CDN

    prerenderedされた殻を返す。このHTMLには以下が含まれる:

    • static部分全て(header、nav、body、footer)
    • <p slot="fallback">読み込み中...</p> がislandの場所にliteral HTMLとして入っている
    • islandをfetchして差し替える小さなインラインの <script>
  2. リクエスト2: GET /_server-islands/StockPanel?p=<encrypted-props> → SSRランタイム

    ページロード後にそのインラインスクリプトが起動。AstroのSSRランタイム(Worker / Netlify Function / Nodeプロセス)にヒット。ランタイムは <StockPanel> コンポーネントだけをサーバー側で描画し、HTMLフラグメントを返す。

  3. DOMスワップ

    インラインスクリプトがHTMLフラグメントを受け取り、fallbackノードを置き換える。

あなたの直感は正しくて: fallbackは本物、スワップはJS駆動、HTTPリクエストは2つ。「server islands」の”server”は「誰が islandのHTMLを描画するか」(ブラウザではなくSSRランタイム)を指し、「どう islandがDOMに辿り着くか」(JS fetch + innerHTML的スワップ)ではない。

しかし厳密な意味での「ハイドレーション」ではない

ここが精度の必要なところ — 普通はセットで起きるが必ずしも一緒とは限らない2つの別物を混同しています:

  • ハイドレーション = ブラウザがフレームワークランタイム(React / Preact / Svelte / Vue)をダウンロードし、サーバー描画HTMLにアタッチして、ライブなコンポーネントツリーとして引き継ぐ。イベントハンドラが配線され、stateがリアクティブになる。コンポーネントがブラウザの中で「生きている」状態。
  • DOM挿入 = JSがどこかからHTMLを受け取り、ページに置く。フレームワークは何も引き継がない。単なるHTML。

Server islandsはDOM挿入であってハイドレーションではありません。差し込まれたHTMLは不活性 — React/Preruntimeはロードされず、stateもevent handlerもない(そのHTML自体が持っている <a> / <form> / <button onclick=""> の限り)。islandをその後インタラクティブにしたければ、server islandの client:load islandを入れ子にして、その内側だけハイドレーションコストを払う。

2つを横並びで比較:

<StockPanel client:load />(ブラウザisland)<StockPanel server:defer />(server island)
HTMLリクエスト数1(CDNのみ)2(CDN + SSRランタイム)
islandのHTMLを誰が描画?ブラウザ、コンポーネントフレームワーク(React/Preact)がSSRランタイム、Astroのサーバーレンダラが
クライアントへ送られるフレームワークランタイムあり(React/Preact JSバンドル)なし(内側にclient islandを入れ子にしない限り)
データfetchはブラウザ、useEffect などでサーバー、コンポーネントのfrontmatterで
ロード中のfallback初期stateとしてレンダリングしたものprerenderedページの <slot name="fallback"> のliteral HTML
SEOが動的データを見る?No(ブラウザがロード後にfetch)No(CDNキャッシュにはfallbackしか入っていない)— 初回描画SEOに関してはclient islandと同じ
殻のCDNキャッシュ性ありあり — これこそがこのモードの存在意義
SSRアダプタが必要?不要必要/_server-islands/* ルートはSSR

最後の2行がserver islandsの存在理由。欲しいのは:

  • 殻はCDNに乗せたい(速い、安い、全員でキャッシュ共有)
  • 動的な部分はサーバーレンダリングしたい(数値を出すだけのためにReactバンドルをクライアントに送りたくない)
  • しかもページ全体をSSRしたくない(それをやると殻のCDNキャッシュが死ぬ)

Server islandsはこの3つ全てを満たす。コストは: /_server-islands/* 用のSSRランタイムをどこかに置く必要があること、そしてユーザーは1ラウンドトリップ「読み込み中…」を見ること。

なぜ一本のstreaming responseにしないのか

当然の疑問: 「殻を先にstreamしてislandを後からstreamする1リクエストで良くない?」 一部のフレームワークは実際それをやる(React Server Componentsのstreaming、Next.js App Routerのstreaming SSR等)。Astroは意図的に2つの別リクエストを選んだ。理由は:

  • 殻とislandが同じレスポンスで来ると、CDNは全体をまとめてキャッシュするかバイパスするかしかできない。殻をキャッシュしつつislandだけフレッシュにする、が1レスポンスでは不可能。
  • 2リクエストなら、殻はエッジで数ヶ月キャッシュしつつ、islandリクエストは毎回SSRランタイムに送れる(あるいはそれ自身でより短いTTLでキャッシュ)。
  • 殻がSSRランタイムを必要としない構成にもなる — 殻は100% staticなビルド出力。SSRランタイムが落ちていても殻は表示される(fallback付きで)。

独立したキャッシュ性1ラウンドトリップのレイテンシより優先したアーキテクチャ判断。CDNで稼ぎたいコンテンツサイトには納得の選択。

で、server:defer は「本物のSSR」なのか「JSハイドレーション」なのか?

実際はハイブリッドで、カテゴリに収まらない違和感は正当:

  • islandのHTMLは本物のSSRが作る(前のメッセージの表の行3 — リクエストごとのサーバー描画)。
  • islandのページへの到着はJS駆動(行2の仕組み — クライアント側fetch + DOM書き換え)。

前の質問の「A」にも「B」にもきれいに収まらない。「Aの出力をBの配管で貼り付ける」。だから第3の何かと呼んだ。SSGかSSRの2択には収まらず、殻はSSG + フラグメントだけSSR + クライアント側オーケストレーション。

クリーンな言い換え: server islandsはAstroにとってのESI(edge-side includes)で、ただしエッジではなくブラウザ側で実装されている。殻がサブフラグメントを参照し、何かがそのサブフラグメントを殻に貼り合わせ、ユーザーは1つの合成ページを見る。伝統的なESIはCDNレイヤ(Varnish、Akamai、Fastly)でやる。AstroはCDNレベルのESIが普遍的に使えるわけではないので、クライアント側でやれば任意のstaticホストで動く。

実用上の注意点

islandが初回HTMLの後に到着し、スワップがJSで起きる結果:

  • JSを無効化しているユーザーは永遠にfallbackしか見ない。 client islandと同じ注意点 — no-JSユーザーは動的部分に関して二級市民。
  • レイアウトシフトが起こりうる。fallbackと本物のislandでサイズが違うとガクッとなるので、CSSでスペースを確保すること。
  • islandは <head> には入れられない(少なくとも <head> に何かを入れようとすると厄介)。マウント後にbodyにspliceされるため。メタタグやog:imageはprerenderedな殻から来るしかない。

SEO的に初回描画で見せなきゃいけない動的データについては、server islandsは 間違った道具。ページ全体をSSR(行3)にすべきで、server islandsではない。Server islandsは「0.5秒遅れて出てきても構わない動的データ」向け。