【第31回】今あるサイトを壊さずに機能を足す|Pages Functions(嫁子が教えるTS講座)

今回のゴール

最終回です。第29回のWorkersと第30回のD1を、実際にこのブログへ足します。全体の流れは講座の目次へ。


嫁子: 最後は実戦だよ。教材用のフォルダじゃなくて、今読んでるこのサイトに足すの。

こういうとき、つい全部作り直したくなるでしょ。今日いちばん伝えたいのは、その反対のことなんだよね。


1. 作り直さない、という選び方

このブログは記事のファイルからHTMLを組み立てて置いてあるだけで、第4部で学んだNext.jsは使っていません。

ここに「役に立ったボタン」を足したいとき、選択肢は2つあります。

やり方 起きること
サイト全体をNext.jsで作り直す 60本を超える記事の見た目・URL・表示速度が全部やり直しになる
足したい機能だけ別に置く 既存のHTMLは1文字も変わらない

後者を選びます。第29回で「壊れる範囲が小さいほうがいい」と書いたのは、この場面のことでした。

AIに頼むときも同じで、「このサイトをNext.jsで作り直して」と言えば本当に作り直してきます。指示する範囲を自分で決められるかどうかが、ここでの分かれ目です。

嫁子: 全部やり直すほうが、実は楽に感じるの。まっさらから作るほうが考えることが少ないからね。

でも動いてるものを止めてまでやる価値があるかは別の話でしょ。今日足すのはボタン1つだよ。それで作り直すのはおかしいよね。


2. ファイルを置くと、それがURLになる

Cloudflare Pagesには、静的なサイトの隣にサーバー側の処理を置ける仕組みがあります。functions/ というフォルダを作って、その中にファイルを置くだけです。

functions/
  api/
    vote.ts      ← /api/vote になる
public/           ← 今までどおりのHTML

置いた場所がそのままURLになります。第18回でNext.jsのフォルダ構成をやったときと同じ発想で、ルーティングの設定ファイルは書きません。


3. つなぐ設定は、前回とほぼ同じ

wrangler.jsonc を作ります。

{
    "name": "otto-vote",
    "compatibility_date": "2026-09-11",
    "pages_build_output_dir": "public",
    "d1_databases": [
        {
            "binding": "DB",
            "database_name": "otto-vote-db",
            "database_id": "local-placeholder"
        }
    ]
}

d1_databases は第30回とまったく同じです。増えたのは pages_build_output_dir の1行だけで、「HTMLが入っているフォルダはここ」と教えています。

型を作るところも同じです。

npx wrangler types
interface __BaseEnv_Env {
    DB: D1Database;
}

前回と一字一句同じものが出てきます。置き場所がWorkersからPagesに変わっても、設定が型になる仕組みは変わりません。


4. 書くのはこれだけ

functions/api/vote.ts です。

export const onRequestPost: PagesFunction<Env> = async (context) => {
    const body = await context.request.json<{ slug?: unknown; helpful?: unknown }>();
    const slug = String(body.slug ?? "").trim();

    if (slug === "") {
        return Response.json({ error: "slugが空です" }, { status: 400 });
    }

    await context.env.DB.prepare("INSERT INTO votes (slug, helpful) VALUES (?, ?)")
        .bind(slug, body.helpful ? 1 : 0)
        .run();

    const row = await context.env.DB.prepare(
        "SELECT COUNT(*) AS total, SUM(helpful) AS yes FROM votes WHERE slug = ?"
    )
        .bind(slug)
        .first<{ total: number; yes: number | null }>();

    return Response.json({ total: row?.total ?? 0, yes: row?.yes ?? 0 }, { status: 201 });
};

新しく覚えるのは関数名だけです。onRequestPost という名前でエクスポートすると、そのファイルへのPOSTだけを受け持ちます。GETなら onRequestGet で、両方受けるなら onRequest になります。

中身は既にやったものでできています。入力チェックは第24回、? と bind は第30回です。?? は「左が null か undefined なら右を使う」という書き方で、前回のコードにも同じ形で出てきました。

テーブルも前回と同じ作り方で用意します。

CREATE TABLE IF NOT EXISTS votes (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  slug      TEXT    NOT NULL,
  helpful   INTEGER NOT NULL,
  createdAt TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
npx wrangler d1 execute otto-vote-db --local --file=./schema.sql

嫁子: 集計を SUM(helpful) でやってるでしょ。押された数を数え直さずに、データベース側に数えさせてるの。

第30回で done を 0 と 1 で持ったのが、ここで効いてるんだよ。真偽値のままだと足し算できないからね。


5. ボタン側 — ここで全部つながる

記事のHTMLの末尾に足します。

<div id="vote" data-slug="ts-30-d1">
  <button data-helpful="1">役に立った</button>
  <button data-helpful="0">いまいち</button>
  <p id="vote-result"></p>
</div>
document.querySelectorAll("#vote button").forEach((btn) => {
  btn.addEventListener("click", async () => {
    const res = await fetch("/api/vote", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        slug: document.getElementById("vote").dataset.slug,
        helpful: btn.dataset.helpful === "1",
      }),
    });
    const data = await res.json();
    document.getElementById("vote-result").textContent =
      data.total + "件中" + data.yes + "件が役に立った";
  });
});

1つだけ新顔があります。querySelectorAll は第9回の getElementById の仲間で、条件に合う要素をまとめて取ってくる書き方です。ボタン2つに同じ処理を付けたいので使っています。

残りは全部やったものです。

使っているもの どの回か
addEventListener と textContent 第9回(DOM操作)
forEach・=== での比較 第6回(条件分岐とループ)
async / await / fetch 第8回(非同期)
サーバー側で受け取る窓口 第24回(API)
データの保存先 第30回(D1)

Reactもフレームワークも使っていません。ボタン2つのために画面の仕組みごと入れ替える必要はないので、ここは素のJavaScriptで足しています。

嫁子: 第17回で「手で書き換えるのは限界がある」って言ったのと矛盾してない? って思った人、鋭いよ。

あれは画面が10枚あるアプリの話なの。ボタン2つならこれで十分。道具の大きさを、作るものの大きさに合わせるのが第6部で一番言いたかったことだよ。


6. 動かして確かめる

起動します。

npx wrangler pages dev --port 8799

立ち上がると、つながっているものが表示されます。

env.DB (otto-vote-db)   D1 Database   local
[wrangler:info] Ready on http://127.0.0.1:8799

local と出ている間はCloudflareのアカウントが要りません。第29回・第30回と同じです。

実際に叩いた結果です。

POST {"slug":"test-kiji","helpful":true}  → 201 {"total":1,"yes":1}
POST {"slug":"test-kiji","helpful":true}  → 201 {"total":2,"yes":2}
POST {"slug":"test-kiji","helpful":false} → 201 {"total":3,"yes":2}
POST {"slug":""}                          → 400 {"error":"slugが空です"}
POST {"slug":"betsu-kiji","helpful":true} → 201 {"total":1,"yes":1}

記事ごとに別々に数えられています。

そのうえで、このブログの本物のファイル一式(記事62本)をそのまま置いて、第30回の記事にボタンを足してみました。ブラウザで押すと「1件中1件が役に立った」と表示され、他の61本は表示も見た目も何も変わりませんでした。

足したのは functions/api/vote.ts と設定ファイル、それにボタンのHTMLだけです。


7. 公開するときに必要なこと

ここから先はCloudflareのアカウントが要るので、手元では確かめていません。必要になるのは次の2つです。

本番用のデータベースを npx wrangler d1 create で作り、発行された database_id を設定ファイルの仮の値と差し替えます。

そのうえで、Pagesのプロジェクトに同じバインディングを登録します。設定ファイルに書く方法と、Cloudflareの管理画面から登録する方法があります。

⚠️ 1つ注意があります。設定ファイルに pages_build_output_dir を書くと、そのファイルがプロジェクトの設定の正本になります。管理画面で設定していた内容は使われなくなるので、手元で試すための値のまま公開すると、本番がその値で動いてしまいます(Cloudflare公式ドキュメント・2026年9月時点)。


つまずきポイント

onRequestPost しか書いていないところをブラウザで開くと、JSONではなく404ページのHTMLが返ります。これを res.json() に渡すと、原因の分かりにくいエラーになります。実際に試したところ、GET /api/vote は Content-Type: text/html の404でした。

npx wrangler types の実行忘れも前回と同じです。設定を変えたら必ずもう一度動かします。

型チェックのときは、Workersの型とブラウザの型がぶつかることがあります。tsc に dom を混ぜると同じ名前の定義が二重になるので、生成された型定義だけを見るようにしてください。


今回のまとめ


講座の終わりに

31回で、Webの仕組みからデータベース、公開までを一周しました。

ここまでで書いたコードの量は、実はそれほど多くありません。はじめにで書いたとおり、目的は全部を手で書けるようになることではなく、AIに的確に指示して、返ってきたものを判断できるようになることでした。

今のあなたは、AIが出してきたコードに対してこう言えるはずです。

コードを書く力より、この4つを言える力のほうが、実際の作業では効きます。AIは指示された範囲で正確に動くので、範囲を決める側の解像度がそのまま結果になります。

次に何をするかは決めなくていいと思っています。作りたいものが出てきたときに、この講座のどの回を読み返せばいいか分かる状態になっていれば十分です。


嫁子: 最後に1つだけ聞くね。その「役に立ったボタン」、本当に使う?

作れることと、必要なことは別だからね。押されなければデータは貯まらないし、貯まっても見なければ意味がないでしょ。作る前に「これ誰が使うの」を1回考えるのは、31回分の知識より効くことがあるよ。

そのうえで作りたいなら、もう作れるからね。お疲れさま。