概要
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がそういう風に作った)

実装は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ファイル自体が存在しない。
以下、そのときの会話をほぼそのまま残す。
会話
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に即時反映されるんですか? どんな技術を使っているんでしょうか?
Yes — picking a cover writes to the MDX on disk immediately, and the dev server then hot-reloads it. Here’s the exact chain:
Client — components/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:
- Reads the article slug from
location.pathname(/guides/<slug>/). POST /api/dev/youtube-at-pick/with{ articleSlug, id, capture }.- Also updates local React state so the cover refreshes instantly without waiting for HMR.
Server — src/astro/lib/dev-api/youtube-at-pick.ts
- Validates: slug
[a-z0-9-]+, id[a-zA-Z0-9_-]+, capturecapture-HH-MM-SS.jpg. - Opens
src/mdx/guides/<slug>.mdx(and.en.mdxif present — skipped silently if missing). - Regex-matches the
<YoutubeAt>block whoseid="..."matches, then inserts or replaces thecapture="..."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 guard — src/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/outsidesrc/astro/pages/so Astro’s router can’t accidentally pick it up in a build. - Defense in depth: also checks
import.meta.env.DEVat 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
YouTubeCaptureWipuses a similar but separate flow (same dir pattern, its own dialog).- The regex rewriter is per-component-id, so two
YoutubeAtwith the sameidin 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
.mdxinsrc/mdx/guides/, so this flow is guide-scoped (notes/highlights wouldn’t be touched by it as written).
はい — cover選択は即座にディスク上のMDXへ書き込まれ、その後dev serverがホットリロードします。正確な処理の流れは以下のとおりです。
Client — components/mdx/youtube-at/youtube-at.tsx
<YoutubeAt> コンポーネントはdev + WIPモードのときだけ「Pick Cover」ボタンを描画します(videoDir が設定されていて import.meta.env.DEV が true のとき)。クリックすると CaptureGalleryDialog が開き、確定時に以下を行います。
location.pathname(/guides/<slug>/)から記事slugを読み取る。POST /api/dev/youtube-at-pick/に{ articleSlug, id, capture }を送る。- ローカルのReact stateも更新し、HMRを待たずに即座にcoverが更新されるようにする。
Server — src/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 guard — src/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側はこのフローでは触れない。
what is the backend of the api? vite has such feature?
そのAPIのバックエンドは何ですか? Viteにそんな機能あるんでしたっけ?
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:
- Custom Astro integration (
devApiIntegration) hooks intoastro:config:setup. - It checks
command— Astro passes'dev'or'build'. - Only when
command === 'dev'does it callinjectRoute({ pattern: '/api/dev/youtube-at-pick', entrypoint: ... }). - The entrypoint file is deliberately placed outside
src/astro/pages/(insrc/astro/lib/dev-api/) so Astro’s file-based router can’t pick it up on its own. It only becomes a route becauseinjectRouteadds 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
| Layer | Role here |
|---|---|
| Vite | Dev server, file watcher, HMR (reloads the page after the MDX is rewritten) |
| Astro | APIRoute contract, injectRoute, integration lifecycle (astro:config:setup, command) |
| Custom integration | Conditional injectRoute only when command === 'dev', so prod builds don’t need an SSR adapter |
Node fs | Atomic 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は astro の APIRoute として型付けされ、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 build は SSRアダプタ(Netlify、Node等)がないと動かず、[NoAdapterInstalled] で失敗します。dev専用のボタンのためだけにアダプタを入れたくなかったということです。
回避策は src/astro/integrations/dev-api.ts で:
- カスタムAstro integration(
devApiIntegration)がastro:config:setupにフックする。 commandをチェック — Astroは'dev'か'build'を渡してくる。command === 'dev'のときだけinjectRoute({ pattern: '/api/dev/youtube-at-pick', entrypoint: ... })を呼ぶ。- エントリポイントファイルは意図的に
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を包んだラッパーです。
まとめ
| レイヤ | ここでの役割 |
|---|---|
| Vite | Dev server、ファイルウォッチャ、HMR(MDX書き換え後にページを再読み込み) |
| Astro | APIRoute 契約、injectRoute、インテグレーションのライフサイクル(astro:config:setup、command) |
| カスタムinteg. | command === 'dev' のときだけ injectRoute を呼ぶため、prodビルドではSSRアダプタを必要としない |
Node fs | アトミックなMDX書き換え(writeFile → tempファイル → rename) |
二重のガード(integration側の command !== 'dev' チェック + handler側の import.meta.env.DEV)は、将来のリファクタでうっかりこのファイル書き込みエンドポイントがproductionに漏れ出るのを防ぐためです。
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か他のバックエンドシステムを使わない限り、デプロイ後は絶対動かなさそうに聞こえます。
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:
- Prerendered endpoints —
GEThandlers that run atastro buildand output static JSON files. Works on any static host. (e.g.,/api/products.jsonbaked at build time.) - 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). - 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/*.mdxis left behind on the build machine. - Serverless/edge filesystems are read-only or ephemeral. Cloudflare Workers have no filesystem at all. Netlify Functions have
/tmpbut 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/Responseabstraction (nicer than raw Nodereq/res). - Astro already runs it in the same Vite process, so no separate server.
- Integrations +
injectRoutegive a clean way to make it structurally impossible to leak into production (the file isn’t even imported duringastro 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つのシナリオが想定されています。
- 事前レンダリングされたエンドポイント —
astro build時に実行されて静的なJSONファイルを出力するGETハンドラ。どんなstaticホストでも動く(例: ビルド時に焼いた/api/products.json)。 - SSRエンドポイント —
prerender = falseをつけたハンドラはリクエスト時に実行され、アダプタが必須:@astrojs/node、@astrojs/netlify、@astrojs/cloudflare、@astrojs/vercelなど。アダプタがハンドラをそのホストのランタイム向けにコンパイルする。 - ハイブリッド — 上記の混合。
だからそう、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のきれいな抽象が使える(生のNodereq/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向けのコンテンツサイトにとっては正しい判断。
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でそれは可能ですか?
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:
| Mode | When HTML is generated | Server needed at runtime? | Example |
|---|---|---|---|
| SSG / static | Once, at astro build | No — just a CDN serving files | takazudomodular.com today |
| SSR | On every request, at request time | Yes — an always-on function/server | Twitter timeline, dashboard |
| ISR / on-demand | First request builds, subsequent requests reuse cached HTML until invalidated | Yes, same as SSR | Next.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 withprerender = true.output: 'static'+ per-pageprerender = 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:
- Page was built statically at
astro build, cached somewhere (CDN, function cache). - Request comes in — CDN serves the cached HTML instantly.
- 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 viaisr: { 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:
| Option | What happens | Fits Astro how? |
|---|---|---|
| A. Full static + git-backed CMS | Button commits to GitHub → Netlify build hook → full astro build (~3–4 min) → CDN updates | Current architecture. Works, but the edit isn’t instant. |
| B. SSR the affected pages | Mark /guides/[slug] as prerender = false, store covers in a DB/KV, read on every request | Every guide request now runs a function. Slower, more infra, but edits are instant. |
| C. SSR + ISR on Vercel/Netlify | Guide pages are static-by-default, cached at the edge; the POST handler invalidates just that slug; next request regenerates that one page | Needs a platform adapter that supports on-demand invalidation. Astro core doesn’t give you this directly. |
| D. Client-side fetch of dynamic data | MDX ships static, but the cover image URL is fetched from a separate /api/covers/<id> endpoint on page load | Keeps 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/ordersmust 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 / static | astro 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の仕組み:
astro build時にページが静的にビルドされ、どこか(CDN、関数キャッシュ)にキャッシュされる。- リクエストが来たらCDNがキャッシュHTMLを即座に配る。
- キャッシュが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のISR | guideページはデフォルトstatic、CDNキャッシュ、POSTハンドラがそのslugのキャッシュを無効化、次のリクエストで1ページだけ再生成 | on-demand無効化を提供しているプラットフォームが必要。Astroコアは直接提供しない |
| D. クライアント側でdynamic fetch | MDXは静的、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モードに閉じ込めることで、そこを完全にバイパスしている。
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側でキャッシュしているとしても結局フルビルドが必要に見えるんですが。
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:
- Walks your configured sources (glob loaders for
src/mdx/**/*.mdx, custom loaders for APIs/DBs, etc.). - Parses each MDX/JSON file — extracts frontmatter, validates against the Zod schema, compiles the body.
- Writes the result into a data store — a serialized cache at
.astro/data-store.json(plus assets). - Exposes it via
astro:content→getCollection('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:
| Mode | What build produces | Where content data lives |
|---|---|---|
SSG (output: 'static') | Static HTML for every page | Consumed during build, then discarded — HTML is the artifact |
SSR (output: 'server') | A JS server bundle exporting route handlers | Serialized 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 dev や astro build を走らせると、Astroは以下を行います:
- 設定されたsource(
src/mdx/**/*.mdxのglobローダーや、API/DBのカスタムローダーなど)を辿る。 - 各MDX/JSONをパース — frontmatterを抽出し、Zodスキーマで検証し、bodyをコンパイル。
- 結果をデータストアに書き込む —
.astro/data-store.jsonにシリアライズされたキャッシュ(加えてアセット類)。 astro:content経由で公開 →getCollection('guides')、getEntry(...)など。
あなたが想像している「DB的なもの」は実在します。メモリ上にインデックス化されたストアで、ビルド時のプロセスが投入する。クエリはオブジェクトのlookupで、リクエスト時にファイルシステムを読みに行くことはありません。
SSRモードでこれに何が起きるか
決定的な事実: content layerは依然として astro build の時点で実行される。SSRはこれを変えない。変わるのはビルドの成果物の中身です:
| モード | ビルドが作るもの | content dataの置き場 |
|---|---|---|
SSG(output: 'static') | 全ページの静的HTML | ビルド中に消費されてあとは捨てられる — HTMLが成果物 |
SSR(output: '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ランタイムの正当化を一切しなくていい。
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テンプレートの中で?
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 case | Static (content layer) | Live (SSR fetch) |
|---|---|---|
| Product page | Spec, description, photos, brand | Stock count, live FX rate, “N people viewing” |
| Blog post | Article body, frontmatter | Comment count, view count, related-posts from ML service |
| Event page | Event description, venue, date | ”Spots remaining”, waitlist status |
| Dashboard | Page chrome, nav, static help text | User-specific data from DB |
| Guide page | Guide content from MDX | Per-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
| Situation | Answer |
|---|---|
| Data is static-enough to bake | SSG. Don’t overthink it. |
| Data is dynamic but only matters for logged-in users, doesn’t affect SEO | Static 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 rendering | SSR. 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:
- Astro SSR deployed as a Worker —
@astrojs/cloudflare. The whole Astro app is compiled into a Worker..astrofrontmatter is the Worker code. There’s no “calling a Worker” — the page is the Worker. - 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-sidefetch(from islands) or build-timefetch(if the data can be baked). - 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つの別物:
- AstroのSSRをWorkerとしてデプロイ —
@astrojs/cloudflare。AstroアプリまるごとがWorkerにコンパイルされる。.astrofrontmatterがWorkerのコード。「Workerを呼ぶ」のではなく、ページ がWorkerそのもの。 - 静的Astro + 別のWorker — AstroはSSG、CDNにデプロイ。別途Worker/Netlify Functionsが
/api/*にあり、動的部分を担当。ページはそれらをクライアント側(islandから)ないしビルド時にfetchする。 - 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 で現れる。
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なんですか?
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 mode | Runtime data fetch location | Astro name | Infra |
|---|---|---|---|
prerender = true | none | SSG (static) | CDN only |
prerender = true | browser (inside an island) | SSG + client islands | CDN + some API endpoint somewhere |
prerender = false | server (inside .astro frontmatter) | SSR | SSR adapter (Node/Worker/Function) |
prerender = false | both server AND browser islands | SSR + client islands | SSR 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
fetchserver-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.astro→prerender = true, fully static.src/pages/products/[slug].astro→prerender = true, but embeds a<StockBadge client:load />island that fetches stock in the browser. (Pattern B.)src/pages/dashboard.astro→prerender = false, SSR, reads cookies, calls DB in frontmatter, returns user-specific HTML. (Pattern A.)src/pages/search.astro→prerender = 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 islands | CDN + どこかにAPI |
prerender = false | サーバー(.astro frontmatter) | SSR | SSRアダプタ(Node/Worker/Function) |
prerender = false | サーバーとブラウザ両方 | SSR + client islands | SSRアダプタ + 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.astro→prerender = true、完全にstatic。src/pages/products/[slug].astro→prerender = trueだが<StockBadge client:load />islandでブラウザから在庫fetch(パターンB)。src/pages/dashboard.astro→prerender = false、SSR、cookie読み取り、frontmatterでDB呼び出し、ユーザー固有HTMLを返す(パターンA)。src/pages/search.astro→prerender = 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」と書かれていたら「サーバーがテンプレートをリクエストごとに実行する」と読む。islands や client:* ディレクティブを見たらそれはブラウザハイドレーションで、あなたが名付けたBのほう。
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ハイドレーションじゃないんですか?
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:
-
Request 1:
GET /guides/foo/→ CDNReturns 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
-
Request 2:
GET /_server-islands/StockPanel?p=<encrypted-props>→ SSR runtimeInitiated 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. -
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 requests | 1 (CDN only) | 2 (CDN + SSR runtime) |
| Who renders the island’s HTML | Browser, using the component framework (React/Preact) | SSR runtime, using Astro’s server renderer |
| Framework runtime shipped to client | Yes (React/Preact JS bundle) | No, unless the island nests a client island |
| Data fetched… | in the browser, from useEffect or similar | on the server, in the component’s frontmatter |
| Fallback while loading | whatever you render as initial state | literal <slot name="fallback"> HTML from the prerendered page |
| SEO sees the dynamic data | No (browser fetches after load) | No (CDN cache has the fallback, not the island) — same as client island for first-paint SEO |
| CDN cachability of shell | Yes | Yes — the whole point of this mode |
| Needs SSR adapter | No | Yes — 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:
GET /guides/foo/→ CDNprerenderedされた殻を返す。このHTMLには以下が含まれる:
- static部分全て(header、nav、body、footer)
<p slot="fallback">読み込み中...</p>がislandの場所にliteral HTMLとして入っている- islandをfetchして差し替える小さなインラインの
<script>
-
リクエスト2:
GET /_server-islands/StockPanel?p=<encrypted-props>→ SSRランタイムページロード後にそのインラインスクリプトが起動。AstroのSSRランタイム(Worker / Netlify Function / Nodeプロセス)にヒット。ランタイムは
<StockPanel>コンポーネントだけをサーバー側で描画し、HTMLフラグメントを返す。 -
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秒遅れて出てきても構わない動的データ」向け。