はじめに(現象)
当ブログ(https://ganbaranai.tech/)はCloudflare Pagesにデプロイしています。Cloudflare Pagesにデプロイすると、独自ドメインとは別に プロジェクト名.pages.dev(本記事では your-project.pages.dev と表記します)というデフォルトドメインが自動で払い出されます。
問題は、この pages.dev が独自ドメインとまったく同じコンテンツをそのまま配信していたことです。その結果、
pages.dev側のアクセスが増えてしまい、独自ドメインのアクセスと分散する- Googleが
pages.devとganbaranai.techを別ページとして扱い、重複コンテンツとしてSEO評価(被リンクや検索順位)も分散する
という状態でした。やりたいのは「アクセスもSEO評価も独自ドメインに一本化する」ことです。
原因
Cloudflare Pagesはデフォルトドメインを勝手に潰さない
Cloudflare Pagesにカスタムドメインを追加しても、pages.dev は生き続けて同じ内容を配信します。「デフォルトドメインを無効化する」トグルのようなものは用意されていません。つまり放っておくと二重配信が続きます。
SEOメタは既に独自ドメイン向きだった
先に調べたところ、canonicalやsitemap、OGPのURLは既にすべて独自ドメインを指していました。当ブログはAstro製で、これらは astro.config.mjs の site を起点に生成しているためです。
// astro.config.mjs
export default defineConfig({
site: 'https://ganbaranai.tech/',
// ...
})
canonicalは各ページで new URL(Astro.url.pathname, Astro.site) から算出しているので、pages.dev でアクセスされても <link rel="canonical"> は ganbaranai.tech を指します。つまりSEOの「正規化シグナル」自体は正しく出ていました。
足りなかったのは、pages.dev へのアクセス(人間もクローラも)を独自ドメインへ実際に飛ばす仕組み=リダイレクトだけでした。
なぜ_redirectsやRedirect Rulesで解決できないのか
最初に思いつくのは Cloudflare Pages の _redirects ファイルですが、これはパスにしかマッチできず、ホスト名で条件分岐できません。/* https://ganbaranai.tech/:splat 301 と書くと、独自ドメイン側のアクセスまで自分自身へリダイレクトして無限ループになります。
ダッシュボードの Redirect Rules / Bulk Redirects も使えません。これらは「自分が所有するゾーン」に紐づく機能ですが、pages.dev はCloudflare側のゾーンであって自分のゾーンではないため、ルールのターゲットにできないのです。
結論として、ホスト名を見て振り分けるにはPages Functionを使うのが唯一のクリーンな手段でした。
対処
リポジトリのルートに functions/_middleware.js を1つ置きます。Cloudflare Pagesは静的サイトでもルートの functions/ をPages Functionとして自動的に拾い、全リクエストで実行します。
// pages.dev → ganbaranai.tech ドメイン集約リダイレクト。
export async function onRequest({ request, next }) {
const url = new URL(request.url)
// 本番の pages.dev エイリアスのみ独自ドメインへ301転送(パス・クエリ保持)。
// 'your-project.pages.dev' は自分のプロジェクトのデフォルトドメインに置き換える。
if (url.hostname === 'your-project.pages.dev') {
url.hostname = 'ganbaranai.tech'
return Response.redirect(url.toString(), 301)
}
// プレビュー <hash>.pages.dev は配信するが、重複コンテンツ索引を防ぐため noindex を付与。
if (url.hostname.endsWith('.pages.dev')) {
const res = await next()
const withNoindex = new Response(res.body, res)
withNoindex.headers.set('X-Robots-Tag', 'noindex')
return withNoindex
}
// 独自ドメイン等はそのまま静的アセットを配信。
return next()
}
ポイントは3つです。
- ホスト完全一致(
===)で本番エイリアスだけを301転送する。urlのホスト名だけを差し替えるので、/blog/xxx?utm=xのようなパスとクエリはそのまま保持されます。 - プレビューデプロイ(
<hash>.pages.dev)は転送しない。これはデプロイ前のQAで実際にアクセスしたいからです。ただしそのままだと重複コンテンツとして索引されうるので、X-Robots-Tag: noindexを付けて配信します。 - 恒久リダイレクトなので301。検索エンジンが評価を独自ドメインへ統合し、ブラウザやCDNもキャッシュします。目的が「恒久的な一本化」なので302ではなく301が正解です(301はキャッシュが強く効くぶん取り消しにくい、という副作用は許容)。
検証
デプロイ後、curlで挙動を確認します。
# 独自ドメインは200で配信され、リダイレクトループが無いこと
curl -sI https://ganbaranai.tech/ # → HTTP/2 200(location無し)
# 本番pages.devが301で独自ドメインへ飛び、パス・クエリを保持すること
curl -sI 'https://your-project.pages.dev/blog/?utm=x'
# → HTTP/2 301
# location: https://ganbaranai.tech/blog/?utm=x
狙いどおり、パスとクエリを保ったまま301で独自ドメインへ集約されました。
ハマりどころ
middlewareの不具合は「全ページ500」になる
_middleware.js は独自ドメインを含む全リクエストで実行される点に注意が必要です。ここで例外を投げるような書き方をすると、pages.dev だけでなく ganbaranai.tech 本番まで巻き込んで全ページが500になります。純粋な静的サイトのつもりでも、middlewareを1枚挟んだ瞬間に「全リクエストがFunction経由」になるわけです。
対策はシンプルで、デプロイ直後に必ず両ドメインをcurlし、500が出たらダッシュボードで直前の正常デプロイに即ロールバックする運用にしました。ホスト完全一致設計だとプレビューではリダイレクト経路をQAできない(pass-through経路しか通らない)ので、本番デプロイ直後のこの確認が実質的な最終チェックになります。
「静的サイトだから完全無料」ではなくなる(Functionsの無料枠)
これは見落としやすいポイントです。Cloudflare Pagesでは静的アセットへのリクエストは無料・無制限ですが、ルートに _middleware.js を1枚置いた瞬間、静的アセットへのアクセスも含めた全リクエストがPages Functionの呼び出しになります。そしてPages Functionのリクエストは、Workersと共通の無料枠を消費します。
執筆時点(2026年7月)の料金体系はこうです(Cloudflare Pages Functions Pricing より)。
- 無料枠: 1日あたり10万リクエスト(WorkersとPages Functions合算。UTC 0時にリセット)
- 静的アセットへのリクエスト自体は無料・無制限(ただし上記のとおりmiddlewareを挟むと呼び出しは発生する)
- 無料枠超過: 100万リクエストあたり $0.30(Workers Paid の $5/月プランに月1000万リクエストが含まれる)
つまり「静的サイトなんだから完全無料でしょ」という前提は、middlewareを入れた時点で厳密には崩れます。とはいえ、個人ブログ規模で1日10万リクエストに達することはまず無いので、実害はほぼありません。「無料枠がある」ことと「その枠を消費し始める」ことは認識しておく、くらいの温度感で十分です。
リクエスト数を抑えたい場合は、_routes.json で静的パスを exclude する手があります(Pages Functions Routing)。除外したパスはFunctionを通らず無料の静的配信に戻ります。ただし除外したパスではリダイレクトもnoindexも効かなくなる点に注意。ホスト集約の目的ならHTMLページさえリダイレクトできれば十分なので、/_astro/* のようなアセット系だけ除外してHTMLルートはFunctionに通す、という折衷ができます。
デプロイが「unable to submit build job」で失敗した
今回いちばん焦ったのはこれです。マージ後のデプロイが Initializing 段階の2秒で Failed: unable to submit build job となって失敗しました。
一瞬「middlewareの追加が原因か?」と疑いましたが、これはCloudflare側のビルドインフラの一過性エラーで、コードとは無関係でした。Initializing はリポジトリの中身を読む前のビルドジョブ投入段階なので、そもそもこちらのファイル追加が影響する余地がありません。少し待って再デプロイしたら問題なく通りました。同じエラーに出会ったら、まずはリトライ、直らなければ Cloudflare Status を確認、が正解です。
将来SSR化すると静かに壊れる罠
Cloudflare Pagesは、ビルド出力に _worker.js が存在すると、ルートの functions/ を完全に無視します。いまは静的サイトなので問題ありませんが、将来 @astrojs/cloudflare を入れてSSR化すると _worker.js が生成され、このリダイレクトが何のエラーも出さずに効かなくなります。ハマると原因特定が厄介なので、functions/_middleware.js の冒頭に「SSR化するときはリダイレクトを _worker 側へ移すこと」というコメントを残しておきました。
今後
- SEO統合の監視: 301は入れた瞬間に効くものではなく、検索エンジンが評価を統合するまで数日〜数週間かかります。Google Search Consoleの独自ドメイン側プロパティで、
pages.devのインデックスが減って独自ドメインへ寄っていくのを見守ります。なお、GSCには「アドレス変更」ツールもありますが、これは本来ドメイン全体の移転向けで、旧・新の両プロパティの所有確認が要る大掛かりな手続きです。今回のようなケースは301だけで評価は十分に統合されるので、無理に使う必要はありません。実質的な集約レバーは301そのものです。 - SSR化時の移設: 上記のとおり、将来SSRを導入するならリダイレクトを
_worker側へ移します。
集約できたら、リダイレクトを外して完全無料に戻せる?
「アクセス解析で独自ドメインへの流入が十分増えたら、今回のmiddlewareを外して完全無料に戻せるのでは?」という発想は自然です。実際、functions/_middleware.js を削除すればサイトは純粋な静的配信に戻り、Functionの呼び出しはゼロ=文字どおり完全無料になります。
ただ、外すのはおすすめしません。 理由は2つです。
- 301こそが集約を維持している装置だから。リダイレクトを外すと
pages.devはまた200で同じコンテンツを配信し始めます。「独自ドメインへの流入が増えた」のは、そもそもこの301が効いているからでもあります。外した瞬間、検索結果にまだ残っているpages.devのURL、他サイトからの被リンク、古いブックマーク経由の訪問者は、もう独自ドメインへ飛ばされません。流入は再び二重化に向かいます。canonicalタグは残るので完全に元通りにはなりませんが、いちばん強いシグナルである301を手放すことになります。 - 外して浮くコストがほぼゼロだから。前述のとおり無料枠は1日10万リクエストで、個人ブログのアクセスはそれより桁違いに小さいのが普通です。つまりmiddlewareは実質すでに無料同然で、外しても節約できる金額はまずありません。
さらに言うと、「完全無料(=Function無し)」と「集約の維持」を両立する静的のみの手段は存在しません。_redirects や _headers はホスト名で条件分岐できず、pages.dev を無効化するトグルも無いからです。結局、ホスト単位の集約を続ける限りこのFunctionが要ります。
というわけで、今回の実装は「一度入れたら基本入れっぱなし」でよいものです。ほぼ無料で恒久的に集約を守り続けてくれる保険、と捉えるのが実態に合っています。
まとめ
- Cloudflare Pagesの
*.pages.devは放置すると独自ドメインと二重配信になり、アクセスとSEO評価が分散する。 _redirectsはホスト条件分岐不可、Redirect Rulesはpages.devを対象にできない。ホスト単位のリダイレクトはPages Functionが唯一のクリーンな手段。functions/_middleware.jsでホスト完全一致の301+プレビューnoindexを実装。数行で済むが、全リクエスト経由になる副作用と_worker.jsの罠には注意。
