本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

本記事ではホスト名を一般化したプレースホルダで表記しています。例:

  • 組織のドメイン: lod.example.or.jp
  • Pages プロジェクト: <edge>.pages.dev
  • 本体の Worker: <app>

数値・コマンド出力は実測値をそのまま載せています。

組織ドメインのサブドメインをCloudflareに向けたい場合、外部のDNS(Domain Name System)にCNAMEレコード(Canonical Name、ある名前を別の名前の別名として扱うDNSレコード)を1本追加してもらう方法が使えます。これは実際に動くことを別記事で確認しました(外部DNSのサブドメインをCloudflare Pagesに割り当てる手順の実測)。

ただしこの方法が使えるのはCloudflare Pagesの場合だけで、Workersでは対象外です。そしてNext.jsをCloudflareで動かすためのアダプタは、いまWorkers側に寄っています。

本記事は、この食い違いをPagesを受け口だけに使うことで解いた記録です。実際に組んで動かし、費用の構造が変わらないことまで確認しています。

何が食い違っていたか

Next.js を Cloudflare で動かす場合に、欲しいものが 2 つあり片方ずつ別のプロダクトにしかないことを示した表形式の図。Pages と next-on-pages の組み合わせは外部 DNS のカスタムドメインが使えるが、アダプタは 2025 年 9 月 29 日に read-only 化され非推奨。Workers と OpenNext の組み合わせは外部 DNS のカスタムドメインが使えないが、アダプタは現行で保守されている公式の後継。どちらか一方を選ぶと、もう一方を諦めることになる。2026 年 8 月時点の状況。

@cloudflare/next-on-pagesアーカイブされています

This repository was archived by the owner on Sep 29, 2025. It is now read-only.

The next-on-pages package is deprecated, if you want to deploy a Next.js application on Cloudflare, please use the OpenNext Cloudflare adapter instead.

後継として案内されているOpenNextのCloudflareアダプタは、Workers向けです。ドキュメント上、Pagesへの対応は書かれていません。

The @opennextjs/cloudflare adapter lets you deploy Next.js apps to Cloudflare Workers using the Node.js "runtime" from Next.js.

一方、外部DNSのカスタムドメインについては、Pages から Workers への移行ガイドにある compatibility matrixがPagesのみ対応と示しています。

Custom domains outside Cloudflare zones   Workers ❌   Pages ✅

Unlike Pages, Workers does not support any domain whose nameservers are not managed by Cloudflare.

つまり、組織ドメインのサブドメインを使いたいならPagesに留まるしかなく、そうするとアーカイブ済みのアダプタを抱え続けることになります。

CloudflareはPagesの廃止を告知していませんが、Pagesのドキュメントの冒頭には次の案内が出ています。

Workers supports most Pages use cases and offers a broader feature set. It is Cloudflare's primary platform for building applications. Start new projects with Workers.

制約を読み直す

制約は「外部DNSのカスタムドメインを付けられるのはPagesだけ」という一点です。SSR(server-side rendering、サーバー側でページを組み立てる方式)をPagesで動かす必要はありません。

そこで、ドメインの受け口だけをPagesにして、処理はWorkersに渡します。

入口だけ Pages、中身は Workers という構成の図。前段は 20 行ほどで、Next.js に依存しないので以後ほぼ書き換えない。組織のドメイン lod.example.or.jp から外部 DNS の CNAME 1 本で Pages プロジェクト(入口、_worker.js と _routes.json だけ)に入り、Service binding(追加のリクエスト課金なし)で Workers プロジェクト(本体、Next.js と OpenNext)に渡り、そこから D1 に繋がる。静的ファイルは _routes.json で除外されており、Worker を呼ばないので無料・無制限のまま。受け口が Pages なので外部 DNS が使え、処理は Workers なので現行のアダプタが使える。

PagesのFunctionsからWorkersへのService bindingは公式機能です。Pages Functionsのbindingsのドキュメントに、設定方法とコード例が載っています。

export async function onRequestGet(context) {
	return context.env.SERVICE.fetch(context.request);
}

費用についてもWorkersの料金ページに明記があります。

Requests made from your Worker to another worker via a Service Binding do not incur additional request fees.

ただし同じページに但し書きがあります。旧来の課金プランでは扱いが違います。

Only available on Workers Standard pricing — If your Worker is on the deprecated Bundled or Unbound pricing plans, incoming requests from Service Bindings are charged the same as requests from the Internet.

現行のプランであれば、転送しても請求は増えません。遅延についてもService bindingsのドキュメントに記述があります。

By default, both Workers run on the same thread of the same Cloudflare server.

When you use Service Bindings, there is zero overhead or added latency.

この構成の要点は、入口のPagesプロジェクトがNext.jsに一切依存しないことです。中身は「受け取ったリクエストをそのまま渡す」だけなので、フレームワークを変えても、Next.jsのバージョンを上げても、ここは書き換え不要です。

組んでみる

1. 入口の _worker.js

Pagesの出力ディレクトリの直下に _worker.js を置くと、Pagesはそれを1つのWorkerとして扱います(Advanced Mode)。

なお公式のサンプルは、静的ファイルへのフォールバックを必須としています。

Your Function is required to make or forward requests to your project's static assets. Failure to do so will result in broken or unwanted behavior.

下のコードに env.ASSETS.fetch() が出てこないのは、静的パスを後述の _routes.json で除外し、前段に届かないようにしているためです。除外しない構成にするなら、フォールバックが要ります。

/**
 * Pages 前段。役割はひとつだけ: 外部 DNS のカスタムドメインを受け取り、
 * Service binding で本体の Worker に丸ごと渡す。
 * X-Shim ヘッダは検証用で、誰が返したかを外から見分けるために付けている。
 */
export default {
  async fetch(request, env) {
    if (!env.SSR) {
      return new Response("service binding SSR が未設定です", { status: 503 });
    }
    const res = await env.SSR.fetch(request);
    const out = new Response(res.body, res);
    out.headers.set("X-Shim", "pages-edge");
    return out;
  },
};

2. Service binding の設定

ダッシュボードからも設定できますが、wranglerの設定ファイルに書くほうが再現性があります。

{
  "name": "<edge>",
  "pages_build_output_dir": "dist",
  "compatibility_date": "2025-04-01",
  "compatibility_flags": ["nodejs_compat"],
  "services": [
    { "binding": "SSR", "service": "<app>" }
  ]
}

はじめはAPI(PATCH /accounts/{id}/pages/projects/{project}deployment_configs.production.services を渡す形)で設定しようとしたのですが、レスポンスは成功を返すのに保存されず、あとから読み直すと services: null のままでした。設定ファイル経由に切り替えたところ、そのまま通りました。

もう1点、wrangler pages deploy は設定ファイルのパスを指定できません。

✘ [ERROR] Pages does not support custom paths for the Wrangler configuration file

設定ファイルを置いたディレクトリをカレントにして実行する必要があります。

3. 動かしてみる

デプロイして開くと、本体の中身がそのまま返りました。

$ curl -s -o /dev/null -w 'HTTP %{http_code} / %{size_download} bytes\n' https://<edge>.pages.dev/
HTTP 200 / 118040 bytes

$ curl -sI https://<edge>.pages.dev/ | grep -i '^x-shim'
x-shim: pages-edge

Next.jsのページも、D1を引く検索ページも通りました。

$ curl -s -o /dev/null -w 'HTTP %{http_code} / %{size_download} bytes\n' https://<edge>.pages.dev/en/search/
HTTP 200 / 227826 bytes

費用の構造が変わらないか確かめる

ここが本記事でいちばん確かめたかった部分です。

PagesもWorkersも、静的ファイルへのリクエストは無料です。Pages Functionsの料金Workers Static Assetsの料金に、ほぼ同じ文言があります。

Pages 側:

On both free and paid plans, requests to static assets are free and unlimited. A request is considered static when it does not invoke Functions.

Workers 側:

Requests to static assets are free and unlimited. Requests to the Worker script (for example, in the case of SSR content) are billed according to Workers pricing.

無料になるのは「アセット層で完結したとき」です。Workers Static Assetsのドキュメントにこうあります。

By default, if a requested URL matches a file in the static assets directory, that file will be served — without invoking Worker code.

ところがAdvanced Modeでは、前段の _worker.js が受信リクエストをすべて握ります。

In advanced mode, your Function will assume full control of all incoming HTTP requests to your domain.

Pagesのルーティングのドキュメントにも、同じことが費用の文脈で書かれています。

On a purely static project, Pages offers unlimited free requests. However, once you add Functions on a Pages project, all requests by default will invoke your Function. To continue receiving unlimited free static requests, exclude your project's static routes by creating a _routes.json file.

実際、前段で全部を転送する状態にしてPages側に静的ファイルを置いても、返ってきたのは本体側の404ページでした。Pagesのアセット層に届く前に、前段が受け取っていたわけです。

当初これをService binding側の性質だと考えていたのですが、一次情報では確認できませんでした。原因は前段を置いたこと自体にあります。

_routes.json で除外する

そこで _routes.json を使います。仕様と、費用への効きめが同じページに書かれています。

exclude: Defines routes that will not be invoked by Functions. Accepts wildcard behavior. exclude always take priority over include.

Any route inside the /build directory will not invoke the Function and will not incur a Functions invocation charge.

仕組みが効くかどうかを見たかったので、検証では1つのファイルだけを除外しました。

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/static-test.txt"]
}

除外したパスを引くと、X-Shim ヘッダが付きません。前段のコードを通っていない、つまりWorkerを呼んでいない、ということです。

なお、ルーティングのドキュメントに _worker.js という語は出てきません。_routes.json がAdvanced Modeにも効くことは、調べた範囲では明記されていませんでした。以下は実測の結果です。いずれも1回の計測です。

除外したパスは前段のコードを通っていないことを示した実測の図。/static-test.txt は _routes.json で除外されており、応答に X-Shim ヘッダが無く、Pages が返していて無料。/(ページ)は除外していないため X-Shim ヘッダがあり、前段から Workers に渡っていて Worker 1 回分の課金になる。前段が付ける X-Shim ヘッダの有無で、前段のコードを通っていないことを外から確かめられる。

$ curl -s https://<edge>.pages.dev/static-test.txt
static-asset-served-by-pages

$ curl -sI https://<edge>.pages.dev/static-test.txt | grep -i '^x-shim'
(何も返らない = Pages のアセット層で完結)

$ curl -sI https://<edge>.pages.dev/ | grep -i '^x-shim'
x-shim: pages-edge

除外したパスは前段のコードを通っていないので、ドキュメントの記述どおりであれば、静的ファイルは無料のまま、SSRだけがWorkersの枠を使うことになります。いまのPagesと同じ費用構造です。

本番では、静的ファイルが置かれるパスをまとめて除外することになります。

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/_next/static/*", "/data/*", "/favicon.ico"]
}

ただしこちらの形は、上の実測とは別です。除外したパスの実体をPages側に置いていないと、当然ながら404になります。実際、実体を置かずに /_next/static/* を除外したところ、ページのJavaScriptが読み込めなくなりました。本番では .open-next/assets をPages側にコピーしたうえで除外します。

外部DNSのカスタムドメインを付けて通す

最後に、この構成に外部DNSのサブドメインを付けました。使ったのは、別記事で2回目の計測として載せたのと同じ登録です(Cloudflareにゾーンを1つも登録していないドメイン、ネームサーバーはAWS Route 53のみ)。

カスタムドメインの登録時、応答に zone_tag が入りません。Cloudflare側に親ゾーンが無いことの裏づけになります。

{
  "name": "test.example.jp",
  "status": "initializing",
  "validation_data": { "method": "http" },
  "certificate_authority": "google"
}

CNAMEを1本追加すると、通りました。

[7] CF: active / active | 解決: 172.66.47.198 | HTTPS: 200

解決 は複数返るAレコードのうちの1つです。以降の --resolve でも同じ値を使っています。)

$ curl -s --resolve "test.example.jp:443:172.66.47.198" \
    -o /dev/null -w 'HTTP %{http_code} / %{size_download} bytes / TLS verify %{ssl_verify_result}\n' \
    https://test.example.jp/
HTTP 200 / 118040 bytes / TLS verify 0

$ curl -sI --resolve "test.example.jp:443:172.66.47.198" https://test.example.jp/ | grep -i '^x-shim'
x-shim: pages-edge

$ echo | openssl s_client -connect 172.66.47.198:443 -servername test.example.jp 2>/dev/null \
    | openssl x509 -noout -subject -issuer
subject= /CN=test.example.jp
issuer= /C=US/O=Google Trust Services/CN=WE1

外部DNSのサブドメインから、前段を経て、OpenNextで動く本体まで通っています。

デプロイの手順が2つになる(未検証)

本体のWorkerと入口のPagesで、デプロイ先が2つになります。GitHub Actionsなら、既存のワークフローに1ステップ足す形になります。以下は今回の検証をもとに書いた案で、まだ本番のワークフローには載せていません。

- run: npx opennextjs-cloudflare build
- run: cp -r .open-next/assets/. edge/dist/
- run: npx wrangler pages deploy edge/dist --project-name <edge>   # 入口(先)
- run: npx opennextjs-cloudflare deploy                            # 本体(後)

順序は入口を先にする想定です。本体を先にすると、新しいHTMLが「まだ置かれていない静的ファイル」を参照する時間ができます。入口を先にしても、古いHTMLが参照していたハッシュ付きのファイルは入れ替わるので切れ目は残りますが、本体のデプロイが済んだ時点で参照先は揃います。

入口の _worker.js_routes.json はリポジトリに置いた固定ファイルで、書き換わりません。毎回変わるのは静的ファイルのコピーだけです。

気をつける点

  • 公式のレシピではありません。2つの公式機能を組み合わせたもので、この組み合わせ自体がドキュメント化されているわけではありません。動くことは確認しましたが、将来にわたって同じ挙動である保証はありません
  • CPU時間の上限は合算です。Workersの料金ページによれば、Service binding経由の呼び出しは「the total amount of CPU time used across both Worker A and Worker B」で計算されます。前段はほぼ0なので実質は変わりませんが、無料プランの上限(1呼び出しあたり10ミリ秒)に近い処理があるなら頭に置いておく必要があります
  • Cloudflare Accessは、外部DNSのカスタムドメインでは対象外です。Accessのドキュメントは「An active domain on Cloudflare」を前提としています。そのアドレスは公開状態になります(Pagesのプレビュー用デプロイにはAccessを適用できます)
  • _routes.json の除外対象は、実体をPages側に置くこと。除外だけすると404になります

まとめ

  • 外部DNSのサブドメインを使いたい要件と、保守されているNext.jsアダプタを使いたい要件は、Cloudflareでは別のプロダクトに分かれています
  • ドメインの受け口だけをPagesにして、Service bindingで本体のWorkerに渡すと、両方を満たせました
  • Service binding経由の呼び出しに、追加のリクエスト課金は発生しません。CPU時間は呼び出し側と呼び出し先の合算です
  • _routes.json で静的パスを除外すると、そのリクエストは前段のコードを通らず、無料のまま配信されます。X-Shim のようなヘッダを付けておくと、外から確かめられます
  • 入口はNext.jsに依存しないので、以後ほぼ書き換えません
  • デプロイ先は2つになりますが、順序(入口が先)さえ守ればワークフローに1ステップ足すだけです

参考