AstroでISR的なキャッシュ戦略を組んで、更新頻度の違うページを捌いた話
Astroで更新頻度が異なるコンテンツをISR的に配信する方法を、Vercelアダプターとキャッシュヘッダーの実装経験から解説する
ブログのトップページは1日1回しか変わらないのに、コメント付きの記事ページは数分おきに更新される。この2つを同じSSRで毎回レンダリングしていたら、Vercelの関数実行時間がじわじわ増えて請求に反映され始めた。
AstroでISR(Incremental Static Regeneration)的なキャッシュを入れれば解決するはず——と思って調べたら、話はそう単純ではなかった。
「Astro 5.2にISRがある」は正確ではない
先に事実を整理しておく。Astro 5.2(2025年初頭リリース)の新機能はTailwind CSS v4対応、トレイリングスラッシュの自動リダイレクト、TOML frontmatter対応などで、ISRというネイティブ機能は入っていない。
AstroでISR的な挙動を実現するには、大きく2つのルートがある。
- Vercelアダプターの
isrオプション(@astrojs/vercel@7.2.0以降) Cache-Controlヘッダーを自分で設定する(Netlify、Cloudflare、Node.js等)
2026年7月時点のAstro 7.0ではAstro.cacheとrouteRulesという公式のルートキャッシュAPIが入ったが、Astro 5系を使っているプロジェクトでは上記2つが現実的な選択肢になる。
Vercelアダプターで全体にISRをかける
一番手軽なのはVercelアダプターの設定だけで済ませるパターン。
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'server',
adapter: vercel({
isr: {
expiration: 60 * 60, // 1時間
},
}),
});
これで全ページが初回リクエスト時にレンダリングされ、以降1時間はキャッシュから配信される。問題は「全ページ一律1時間」という点。トップページは1日キャッシュしたいし、コメント付きページは5分で更新したい。expirationはグローバル設定なので、ページごとに変えられない。
excludeオプションで特定ルートをISRから外すことはできる。
adapter: vercel({
isr: {
expiration: 60 * 60 * 24, // 基本は1日
exclude: ['/api/comments', '/preview'],
},
}),
ただしこれは「キャッシュしない」か「一律の時間でキャッシュする」の二択で、ルートごとに違う有効期限を設定したいケースには足りない。
ページ単位でCache-Controlを分ける
結局たどり着いたのは、各ページの.astroファイルでCache-Controlヘッダーを直接設定するやり方。
---
// src/pages/index.astro(トップページ:1日キャッシュ)
export const prerender = false;
Astro.response.headers.set(
'Cache-Control',
'public, s-maxage=86400, stale-while-revalidate=3600'
);
const posts = await fetchLatestPosts();
---
<Layout>
<PostList posts={posts} />
</Layout>
---
// src/pages/blog/[slug].astro(記事ページ:5分キャッシュ)
export const prerender = false;
Astro.response.headers.set(
'Cache-Control',
'public, s-maxage=300, stale-while-revalidate=60'
);
const { slug } = Astro.params;
const post = await fetchPost(slug);
const comments = await fetchComments(slug);
---
<Layout>
<Article post={post} comments={comments} />
</Layout>
s-maxageがCDN側のキャッシュ期間、stale-while-revalidateがキャッシュ切れ後に古い応答を返しつつバックグラウンドで再生成する猶予期間。ユーザーは常に即座にレスポンスを受け取り、裏で新しいページが生成される。
Netlifyにデプロイする場合はNetlify-CDN-Cache-Controlヘッダーを使い、ブラウザキャッシュとCDNキャッシュを分離するのが定石。
---
// ブラウザには常に再検証させつつ、CDNには長めにキャッシュ
Astro.response.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
Astro.response.headers.set(
'Netlify-CDN-Cache-Control',
'public, durable, s-maxage=86400, stale-while-revalidate=604800'
);
---
更新頻度ごとにキャッシュ時間を決める
自分のブログで実際に設定している値はこんな感じ。
| ルート | 更新頻度 | s-maxage | stale-while-revalidate |
|---|---|---|---|
/(トップ) | 1日1回 | 86400(1日) | 3600(1時間) |
/blog/[slug] | 数日に1回 | 3600(1時間) | 600(10分) |
/api/comments | 数分おき | 0(キャッシュなし) | — |
/about | ほぼ変わらない | 604800(7日) | 86400(1日) |
迷ったら「最悪このくらい古くても許容できる時間」をs-maxageに入れればいい。コメントAPIのように即時反映が必要なものはキャッシュしない。静的に近いページは長めに取る。
middlewareでキャッシュ時間を一元管理する
ページごとにヘッダーを書くのが散らかると感じたら、middlewareで集約できる。
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
const cacheRules: Record<string, { maxAge: number; swr: number }> = {
'/': { maxAge: 86400, swr: 3600 },
'/about': { maxAge: 604800, swr: 86400 },
};
const dynamicRules: Array<{ pattern: RegExp; maxAge: number; swr: number }> = [
{ pattern: /^\/blog\//, maxAge: 3600, swr: 600 },
];
export const onRequest = defineMiddleware(async ({ url, response }, next) => {
const res = await next();
const rule = cacheRules[url.pathname]
|| dynamicRules.find(r => r.pattern.test(url.pathname));
if (rule) {
res.headers.set(
'Cache-Control',
`public, s-maxage=${rule.maxAge}, stale-while-revalidate=${rule.swr}`
);
}
return res;
});
ルートが増えたときにcacheRulesに1行足すだけで済む。各ページの.astroファイルにキャッシュ設定が散在しないので、見通しがいい。
Astro 7のrouteRules——次に移行するならこれ
2026年リリースのAstro 7.0で入ったrouteRulesは、このmiddlewareパターンを公式に設定ファイルレベルでサポートしたもの。
// astro.config.mjs(Astro 7.0以降)
import { defineConfig, memoryCache } from 'astro/config';
export default defineConfig({
cache: {
provider: memoryCache(),
},
routeRules: {
'/': { maxAge: 86400, swr: 3600, tags: ['home'] },
'/blog/[...slug]': { maxAge: 3600, swr: 600, tags: ['blog'] },
'/about': { maxAge: 604800, swr: 86400 },
},
});
タグベースの無効化もできるので、CMSのWebhookを受けて該当ページだけ再生成する、という運用がやりやすくなった。Astro 5系からの移行を計画しているなら、middleware方式で設計しておけばrouteRulesへの移植は機械的にできる。
実際に困ったこと
Vercelアダプターのisr: trueだけ設定してデプロイしたとき、ページにCache-Controlヘッダーを自分で設定していても無視されていた。公式ドキュメントを読み直したら「isr: trueのときはVercelのキャッシュシールドが優先され、アプリのCache-Controlヘッダーは無視される」と書いてある。expirationを数値で指定して初めて、自分のCache-Controlが尊重される仕組みだった。
小一時間デバッグしてからドキュメントに戻って気づいた、という典型的なRTFM案件。
ISRという言葉はNext.jsが広めた概念で、Astroの世界ではアダプター経由の機能かCache-Controlヘッダーの手動設定、あるいはAstro 7のルートキャッシュAPIを使って同等のことを実現する。フレームワークにISRという名前のAPIがなくても、CDNキャッシュとstale-while-revalidateを正しく設定すれば実質的に同じことができる。名前より仕組みを理解しておくほうが応用が利く。