Cloudflare Pages Functions で API を 1 ファイル化する最小構成
Cloudflare Pages で静的サイトを公開すると、しばらくは「サーバーは要らない」状態で個人開発の本番運用が回ります。けれど、ある日 「フォームを受け取りたい」「Webhook を 1 本受けたい」「外部 API キーを隠して中継したい」 という用途が顔を出します。
このとき、Workers 単独プロジェクトを立てる のが本筋なのですが、サイトに密接した 1 つの API(同じドメインの /api/xxx)のために別プロジェクトを管理するのは、少し重い。デプロイも別、ドメイン設定も別、ローカル開発の起動コマンドも 2 つ。
Pages Functions は、このギャップを埋めるために用意された仕組みです。Pages プロジェクトのリポジトリの中に functions/ ディレクトリを 1 つ作り、functions/api/hello.ts のようにファイルを置くだけで、https://<site>/api/hello で動く Worker が一緒にデプロイされます。本記事では、その最小構成と、Workers 単独との使い分けを、一次情報で通します。
Pages Functions とは(Workers 単独との違い)
両者は同じ Workers ランタイム(workerd)の上で動きますが、どこに住んでいて、誰がデプロイするかが違います。
| Pages Functions | Workers 単独 | |
|---|---|---|
| 住所 | サイトと同じドメインの /<path> | 別ドメイン(<name>.workers.dev または custom) |
| デプロイ | サイトと一緒に git push(Pages の自動ビルド) | wrangler deploy または別の Git 連携 |
| 設定ファイル | wrangler.toml 不要・functions/ の構造で決まる | wrangler.toml 必須 |
| 向く用途 | サイトに密接した 1〜5 個の API | 独立サービス・複数フロントから叩く API |
| ローカル起動 | wrangler pages dev | wrangler dev |
| bindings(D1 / R2 / KV) | ◎(dashboard でバケット選択) | ◎(wrangler.toml で書く) |
「サイトの中に住んでいる、サイトと同じ URL の API」── これが Pages Functions のキャラクターです。「独立したサービスとして外から叩かれる API」なら、迷わず Workers 単独で。
novelarena.jp のような Astro 静的サイト + たまに動的処理 の構成では、Pages Functions が最短の正解です。
手順 1:functions/ ディレクトリを作る
Pages プロジェクトのリポジトリのルートに、functions/ ディレクトリを作ります。
my-site/
├── src/ ← Astro / Next.js などのソース
├── public/
├── functions/ ← ★ ここを新設
│ └── api/
│ └── hello.ts
├── package.json
└── astro.config.mjs
ファイル名と階層がそのまま URL になります。functions/api/hello.ts → https://<site>/api/hello。Pages ビルド時に Cloudflare 側が自動で検知し、Worker として一緒にデプロイします。
手順 2:onRequest ハンドラを書く
functions/api/hello.ts の中身は、これだけです。
interface Env {}
export const onRequest: PagesFunction<Env> = async (context) => {
const { request, env } = context;
const url = new URL(request.url);
const name = url.searchParams.get('name') ?? 'world';
return Response.json({ message: `Hello, ${name}` });
};
Workers 単独は export default { fetch } でしたが、Pages Functions は export const onRequest という名前で関数を出します。中身は同じ Workers ランタイムなので、Request / Response / fetch が使えます。
HTTP メソッドごとに分けたい場合は、onRequestGet / onRequestPost / onRequestPut の名前でエクスポートします。
export const onRequestGet: PagesFunction<Env> = async () => {
return Response.json({ ok: true });
};
export const onRequestPost: PagesFunction<Env> = async ({ request }) => {
const body = await request.json();
// ...
return Response.json({ received: body });
};
これだけで、メソッドルーティングが完成します。onRequest 1 つで書いて自分で request.method を見る方式と、メソッドごとにエクスポートする方式、どちらでも動きます。
手順 3:パスパラメータを使う
functions/api/users/[id].ts のようにブラケットで囲んだファイル名にすると、URL のパス部分をパラメータとして受け取れます。
// functions/api/users/[id].ts
export const onRequestGet: PagesFunction = async ({ params }) => {
const id = params.id; // /api/users/42 の "42" が入る
return Response.json({ id });
};
複数階層のキャッチオールには [[path]].ts(ダブルブラケット)。Next.js を触ったことがある人なら、ほぼ同じ感覚です。
手順 4:ローカルで wrangler pages dev
ローカルで起動するには、wrangler pages dev を使います。
# Astro なら一度 build してから(Pages は dist/ を見る)
npm run build
npx wrangler pages dev ./dist
http://localhost:8788 が立ち上がります(Workers 単独の 8787 とは別ポート)。functions/ の変更は自動で拾われます。
curl http://localhost:8788/api/hello?name=mahiro で {"message":"Hello, mahiro"} が返れば OK。
静的ファイル(dist/index.html)も、API(/api/hello)も、同じドメイン・同じポートで配信される ── これが Pages Functions の心臓部です。
手順 5:デプロイは git push だけ
Pages はすでに Git 連携でデプロイが回っている前提です。functions/ の追加と削除も、git push に乗ってそのまま反映されます。wrangler deploy を別途叩く必要はありません。
git add functions/
git commit -m "feat: /api/hello を追加"
git push origin main
数十秒〜数分で Pages のビルドが回り、本番に反映されます。https://<your-site>/api/hello が動き始める。
環境変数と bindings
API キーや、D1 / R2 / KV を Pages Functions の中で使いたい場合は、Cloudflare ダッシュ → Pages → 該当プロジェクト → Settings → Functions から設定します。
- 環境変数(平文):
Environment variablesセクションで Name / Value で追加。context.env.MY_VARで参照。 - シークレット(機密):同じ画面で
Encryptを ON にして追加。コード上は同じくcontext.envで読める。 - D1 / R2 / KV bindings:
D1 database bindings/R2 bucket bindings/KV namespace bindingsセクションで、すでに作ったバケットや DB を選択。
ダッシュから設定するのは、Pages Functions が wrangler.toml を持たないため。Workers 単独だと TOML に書きますが、Pages Functions ではダッシュが正本です。
ローカル開発時の bindings は、.dev.vars ファイル(プロジェクトルート)に書きます。
# .dev.vars(Git に入れない)
MY_API_KEY=xxxxx
D1 / R2 / KV のローカル bindings は、wrangler pages dev に --d1 DB=<db-name> のように引数で渡せます(オプションが増えるので、頻繁に使うなら npm script に固定しておくのが楽)。
挫折ポイント
- ファイル名と階層がそのまま URL:
functions/api/hello.tsは/api/hello、functions/api/users/[id].tsは/api/users/<id>。サイトの静的パス(例:/apiという静的ページ)と重なると、Pages Functions のほうが優先されてしまうので、/api/*配下は API 専用にする習慣。 onRequestかonRequestGetか:両方書くとonRequestが優先されます。メソッド別の挙動を書きたいときはonRequest系を消してonRequestGet/onRequestPostに揃える。wrangler.tomlを書きたくなる:Pages Functions はwrangler.tomlを読みません(Workers 単独との大きな違い)。ダッシュ設定が正本。コードと一緒に env を版管理したい場合は、その API を Workers 単独に切り出すサインです。functions/の中の TypeScript 型:PagesFunction<Env>を読み込むには、@cloudflare/workers-typesを入れてtsconfig.jsonのtypesに追加します。型なしでも動きますが、補完が効くと書き心地が変わる。- 静的ファイルが Functions で上書きされる気がする:Pages は「まず Functions、なければ静的」の順で見ます。サイトの静的ファイル名と URL がぶつからない限り、両立できます。
- ローカル
wrangler pages devが遅い:build を毎回回すと重いので、開発中は Astro / Next.js の dev server を別途立てて、API だけ別ポートで叩く割り切りも有効。本番では同じドメインに統合されるので、本番テストは別途必要。
まとめ:5 ステップで「サイト + 小さな API」が同居する
最小手順を振り返ります。
- プロジェクトルートに
functions/api/hello.tsを 1 つ作る export const onRequest = async (context) => Response.json(...)を書く- パスパラメータは
[id].ts、キャッチオールは[[path]].ts - ローカル確認は
wrangler pages dev ./dist - デプロイは普段の
git push origin main(Pages のビルドに自動で乗る)
ここまで通れば、**「静的サイトの中に、ちょっとだけ動的処理が住んでいる」**構造が手に入ります。フォーム受け、Webhook、外部 API キーの中継、簡単な認証リレー ── どれも functions/api/*.ts の延長で書けます。
そして、「いや、これは独立サービスだ」と感じ始めたら、Workers 単独に切り出すタイミングです。Pages Functions と Workers 単独の境目は、最初から決め打ちしなくても、書き進める中で見えてきます。だいたいの目安は、「API の数が 5 〜 10 を超え始めたら」「複数フロントから叩かれるようになったら」「wrangler.toml を書きたくなったら」── そのどれかが立ったら、別プロジェクトに引っ越します。
姉妹記事として、静的サイトの公開先は Cloudflare Pages、独立 API は Cloudflare Workers、データ層は D1、ファイル層は R2 を書いています。Pages Functions を含めて、Cloudflare 1 アカウントで個人開発のフルスタックが完結する構成、として並べて読めます。
最初の 1 ファイルを、まず functions/api/hello.ts に置くところから。
サイトも API も動かせる、データも残せる。けれど 読まれない/登録に至らない ── その構造を WHY → HOW で 1 冊にまとめたのが、下記の本です。個人開発者・インディー Web 作者・動画作者向けに、LP・短文・メールの戦場別の書き方と、Claude を相棒にしたプロンプト連鎖の実演を含めて統合しています。