3行まとめ
- 公的オープンデータ×全国の地点で約2万ページを静的生成する「データベース型SEO」サイトを Astro で組んだ
- 全ページ静的書き出し(
output: 'static')でやると **Cloudflare Pages の「1デプロイ2万ファイル上限」**にぶつかる - 公開分は Pages、あふれた分は R2 + Pages Functions の 404 フォールバックで配信して制限を超えた
個人開発でツールサイトをやっていると、PV を伸ばす手段として「ページの量産」が難しいという壁にぶつかる。1ツール=1ページなので、ページを増やすにはツールを増やすしかなく頭打ちになる。
そこで仮説検証としてやってみたのがデータベース型SEO。元になる構造化データ(全国の地点 × 公的な災害リスクデータ)があれば、1件1ページでページを機械的に量産できる。公的データ(J-SHIS・地理院・不動産情報ライブラリなど)を UI/UX 整備して配信すれば、データの数だけページが立つ。
フレームワークは Astro。全ページを静的に書き出してフロントにJSは最小限、という構成がSEOページ量産と相性がいい。ただ素直に全静的生成すると、ホスティング側のファイル数制限という別の壁が出てくる。そのあたりの設計をまとめる。
データベース型SEOの基本構造
プログラマティックSEOの作りは、
- 構造化されたデータセットを用意する
- それを流し込む1枚のテンプレートを作る
- データの行数だけページを生成する
というもの。検索する人は「渋谷区 ハザードマップ」「○○市 地震 リスク」のように地名×トピックで具体的に調べるので、その粒度でページが存在すればロングテールを面で拾える。
元データは SQLite に持たせた(better-sqlite3)。全国の市区町村・町丁目マスタとリスクスコアを1ファイルのDBに入れ、ビルド時に読み出す。DBは単一ファイルなのでGit管理でき、月次バッチ(GitHub Actions)で更新→コミット→デプロイのシンプルなフローで回せる。
階層ルーティング
URL は地理階層に素直にマッピングした。Astro の動的ルートをネストする。
src/pages/
├── [pref]/index.astro # 都道府県
│ └── [city]/index.astro # 市区町村
│ └── [ward]/index.astro # 区 or 町丁目
│ └── [area]/index.astro # 町丁目(政令市のみ)
階層が場所によって変わるのがミソ。政令指定都市は「市→区→町丁目」で4階層、通常市は「市→町丁目」で3階層になる。これをデータ側の parent_city_slug(親市スラッグ)の有無で出し分けている。
// [pref]/[city]/[ward]/[area]/index.astro
export async function getStaticPaths() {
const areas = getAllPublishedAreas()
const result = []
for (const a of areas) {
if (!a.parent_city_slug) continue // 通常市は1つ上の階層で生成するのでスキップ
result.push({
params: {
pref: a.pref_slug,
city: a.parent_city_slug,
ward: a.city_slug,
area: a.area_slug,
},
})
}
return result
}
getStaticPaths が返したパスがビルド時にすべて静的HTMLになる。公開対象(is_published)の地点だけで約2万ページ。
不正URL対策
ページ本体では、URLのスラッグから実在チェックをして、無ければ404に落とす。
const { pref, city, ward, area } = Astro.params
const areaData = getAreaBySlug(pref!, ward!, area!)
if (!areaData) return Astro.redirect('/404')
getStaticPaths で列挙したパスしか静的生成されないので基本は問題ないが、後述の動的配信側で任意URLを叩かれたときのためにも、DB照合→無ければ404という入口を統一しておく。
Cloudflare Pages の「2万ファイル制限」という壁
ここが本題。Astro の output: 'static' で全ページ書き出すと、dist/ に2万個近いHTMLファイルが並ぶ。これを Cloudflare Pages にデプロイしようとすると、**1デプロイあたりのファイル数上限(20,000ファイル)**に引っかかる。ページを増やすほどこの壁が近づく。ページ量産型SEOとホスティングのファイル数制限は、根本的に相性が悪い。
選択肢としては、
- ISR/SSR に寄せる → エッジ関数のコスト・複雑さが増す
- ページを間引く → SEOの面取り戦略と矛盾する
- 公開分だけ Pages に置き、あふれた分は別ストレージから配信する ← これを採った
R2 + Functions の404フォールバックで超える
ビルドを2系統に分ける。
{
"scripts": {
"build": "astro build",
"build:dynamic": "BUILD_UNPUBLISHED=true astro build --outDir dist-dynamic"
}
}
-
astro build→ 公開対象だけ →dist/(Cloudflare Pages へ。2万ファイル以内に収める) -
BUILD_UNPUBLISHED=true astro build --outDir dist-dynamic→ 未公開・先行分 →dist-dynamic/(R2 バケットへ同期)
getStaticPaths 側で BUILD_UNPUBLISHED を見て生成対象を切り替えている。
export async function getStaticPaths() {
if (process.env.BUILD_UNPUBLISHED === 'true') return []
// ...通常ビルドは公開分のみ生成
}
そして Pages Functions の functions/_middleware.ts で、Pages 側に存在しない(404)地点URLが来たら R2 から拾って配信する。
export async function onRequest({ request, next, env }) {
const url = new URL(request.url)
const response = await next()
// 静的ファイルが無い(404)かつ地点URLなら R2 から動的配信
if (response.status === 404 && env.DYNAMIC_BUCKET && isAreaPath(url.pathname)) {
const r2Key = urlToR2Key(url.pathname) // /chiba/choshi/nakacho-1/ → chiba/choshi/nakacho-1/index.html
const obj = await env.DYNAMIC_BUCKET.get(r2Key)
if (obj) {
return new Response(await obj.text(), {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
},
})
}
}
return response
}
ポイント:
- まず
next()で通常の Pages 配信を試し、404 のときだけ R2 を見る。公開分は Pages の静的配信が効くので速い -
urlToR2Keyで URL を R2 のキー(末尾にindex.html)へ変換 -
isAreaPathで固定ページ(/about・/search・サイトマップ等)を除外し、地点URLだけを R2 フォールバック対象にする -
Cache-Controlでエッジキャッシュを効かせて、R2 への往復を毎回発生させない
これで「Pages のファイル数上限は超えないが、URLとしては全地点にアクセスできる」状態になる。Pages = 公開分の高速配信、R2 = あふれ分の動的フォールバック、という役割分担。
ついでに _middleware.ts では *.pages.dev のプレビューURLに X-Robots-Tag: noindex を付け、本番 pages.dev から独自ドメインへ301もしている。SEO評価を独自ドメインに集約しつつ、プレビューがインデックスされる事故を防ぐ。
サイトマップとメタデータ
サイトマップは @astrojs/sitemap インテグレーションを入れるだけで、ビルド時に自動生成される。
// astro.config.mjs
export default defineConfig({
output: 'static',
integrations: [sitemap()],
})
ページ数が多いと sitemap は自動で分割される(sitemap.xml は1ファイル50,000URL上限)。インデックスXMLと本体XMLに分かれて出力される。
各ページの <title>・description は地名から動的に組み立て、BreadcrumbList と Place/GeoCoordinates の JSON-LD を埋める。地名×トピックで検索される前提なので、タイトルに地名と「災害リスク」「ハザード」を確実に含めるのが効く。description は短すぎると弱いので120〜140字に寄せている。
まとめ
公的データでページ量産型のDB型SEOを Astro で組むときの要点は、
- 構造化データ(SQLite 1ファイル)×階層ルーティング×
getStaticPathsで、データ行数ぶんのページを静的生成 - 全静的生成は Cloudflare Pages の2万ファイル制限にぶつかる。これがページ量産の地味な天井
- 公開分は Pages、あふれた分は R2 + Functions の404フォールバックで配信して制限を回避。役割分担でスケールさせる
- サイトマップ・JSON-LD・canonical を自動生成して地名×トピックのロングテールを取りにいく
「1ツール1ページ」で頭打ちだったPVを、構造化データの行数でスケールさせられるのがDB型SEOの強み。良質なオープンデータさえあればページは機械的に立つ。あとはホスティングのファイル数制限のような物理的な壁をどう超えるか、という設計勝負になる。
この記事は Zenn にも同じ内容を投稿しています。