A/Bテストで「勝った」が嘘になる瞬間。収益化導線の正しい計測
クリックは増えたのに売上は減った。僕が踏んだ罠を起点に、Claude CodeでA/Bテストの割り当て・計測・有意差・ロールバックを安全に作る手順を解説。
価格ページのCTA文言を変えたら、無料登録が18%伸びました。僕は「勝った」とSlackに投げて、その案を100%に展開しました。
3週間後、有料相談の予約が静かに減っていたことに気づきます。登録は増えたのに、お金を払う人は減っていた。後で調べたら、その週はたまたまニュースレターを2回配信していて、登録だけ伸びやすい人が大量に流れ込んでいただけでした。
A/Bテストで一番怖いのは、負けることじゃない。「勝った」と勘違いして、間違ったほうへアクセルを踏むことです。
この記事の要点
- A/Bテストは「画面をランダムに出す仕組み」ではなく、収益に近い行動を測る実験。コードより先に仮説と指標を決める。
- 割り当ては
localStorageではなくサーバー側(Next.js Route Handler + Cookie)で固定する。初回のちらつきと、同じ人が別案を見る事故を防ぐため。 - 主要指標だけでなくガードレール指標(落としてはいけない数字)をセットで見る。登録が増えても有料転換が落ちたら負け。
- 偽陽性(偶然のブレを勝ちと勘違い)を避けるため、開始前に最小サンプル数と停止条件を決める。毎日のぞいて良い瞬間に止めない。
- Claude Codeは「実装係」ではなく「実験設計をコードに落とす相棒」として使うと、修正依頼が激減する。
A/Bテストは「実験」であって「画面切り替え」ではない
Claude Codeに「A/Bテストを作って」とだけ頼むと、表示を切り替えるコードは数秒で出てきます。でも、それは実験の見た目だけです。仮説、計測イベント、サンプルサイズ、除外条件、戻し方が決まっていない実験は、どんなに数字が良く見えても意思決定に使えません。冒頭の僕がまさにそれでした。
だからこの記事では、Claude Codeを実装係ではなく「実験設計をコードに落とす相棒」として扱います。先に用語をやさしく言い換えておきます。
- バリアント = 比較する案(元の案と新しい案)
- エクスポージャー = ユーザーがどの案を見たかを初めて記録すること
- ガードレール指標 = 改善してはいけない、落としたら負けになる安全確認の数字
- 偽陽性 = 偶然のブレを「勝ち」と勘違いすること
最初に渡すプロンプトは、コードの話ではなく事業の問いから始めます。
Next.js App RouterのSaaS/ブログでA/Bテストを作ります。
目的は収益化導線の改善で、単なるクリック数ではなく、登録、購入リンククリック、広告RPM、LCP、エラー率を一緒に見ます。
実験ID: pricing_page_offer_2026_06
仮説: 価格ページのCTA文言を「Start free trial」から「Start with the free plan」に変えると、有料相談前の無料登録率が上がる。
主要指標: signup_start_rate
ガードレール: purchase_link_click_rateを落とさない、p75 LCPを300ms以上悪化させない、JSエラー率を増やさない。
必要なもの: イベントスキーマ、サーバー側割り当て、Cookieの注意点、BigQuery風の分析SQL、Playwright検証。
候補は3つ以上出しておくと、Claude Codeの出力が現実寄りになります。SaaSなら価格ページのCTA、ブログならアフィリエイト枠の位置、ニュースレターなら登録フォームの見出し、B2Bなら資料請求フォームの項目数。どれも「クリックが増えるか」だけでなく「売上やリード品質を壊さないか」まで一緒に見るのがコツです。機能フラグの基礎はClaude Codeでフィーチャーフラグを実装する方法、計測設計はClaude Codeでアナリティクスを実装する手順も合わせてどうぞ。
| ユースケース | 主要指標 | ガードレール | ハマりやすい点 |
|---|---|---|---|
| SaaS価格ページのCTA文言 | 無料登録開始率 | 有料相談クリック、エラー率、LCP | 登録は増えても有料導線が落ちる |
| ブログの広告/アフィリエイト枠 | 商品リンククリック率 | 読了率、直帰率、広告表示速度 | 収益枠を上に置きすぎて読者体験が悪化する |
| ニュースレター登録フォーム | 登録完了率 | スパム登録率、解除率 | 登録数だけ見てリスト品質を見ない |
| オンボーディング画面 | 初回成功率 | サポート問い合わせ、離脱率 | 短期の完了率だけで長期継続を見ない |
イベント名がバラけると、後で泣く
A/Bテストで僕が一番やらかしたのは、実験を回し終えたあとに「あれ、このイベント名で集計できるんだっけ」と気づいたことです。クリックが button_click、ctaClicked、signup_click みたいに散らばっていて、集計のたびに手で名寄せするはめになりました。
なので今は、UIより先に型付きのイベントスキーマをClaude Codeに作らせます。型で縛っておけば、別名のイベントを送ろうとした時点でエディタが赤くなる。これだけで後工程の事故がごっそり減ります。
Google Analyticsを使う場合、イベントとパラメータの考え方はGA4のイベント仕様を確認してください。カスタムパラメータを後でレポートに出すには、GA4側のカスタム定義が別途必要になることがあります。
// lib/experiment-events.ts
export type ExperimentId = "pricing_page_offer_2026_06";
export type VariantId = "control" | "free_plan_copy";
export type ExperimentEvent =
| {
event_name: "experiment_exposure";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
page_path: string;
}
| {
event_name: "cta_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
cta_id: "pricing_primary" | "article_bottom" | "sidebar_offer";
page_path: string;
}
| {
event_name: "purchase_link_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
product_id: string;
value_usd: number;
page_path: string;
}
| {
event_name: "guardrail_metric";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
metric_name: "lcp_ms" | "js_error" | "bounce";
value: number;
page_path: string;
};
declare global {
interface Window {
gtag?: (command: "event", name: string, params: Record<string, unknown>) => void;
}
}
export function trackExperimentEvent(event: ExperimentEvent) {
if (typeof window === "undefined") return;
// 型で縛った内容だけをアナリティクスに送る
window.gtag?.("event", event.event_name, {
experiment_id: event.experiment_id,
variant: event.variant,
anonymous_id: event.anonymous_id,
page_path: event.page_path,
...event,
});
}
ひとつだけ強く言いたいのは、メールアドレス・氏名・会社名をイベントに入れないことです。匿名IDだけ送る。広告やアナリティクスの同意が必要な地域では、Googleの同意モード資料のように、タグを送る前に同意状態を初期化します。
割り当てはサーバー側で固定する(ちらつきと別案事故を防ぐ)
ブラウザの localStorage だけで割り当てると、初回表示で元の案が一瞬出てから新しい案に切り替わる「ちらつき」が起きます。しかもログイン前後・別端末・プライベートブラウズ・ストレージ制限で、同じ人が別のバリアントを見てしまう。MDNも localStorage を同一オリジンに保存されセッションをまたいで残るストレージと説明していますが(MDN localStorage)、これはサーバー初回描画の割り当てには向きません。
Next.jsなら、まずRoute Handlerでサーバー側に割り当てるのが扱いやすいです。公式のroute.tsドキュメントはRoute HandlerをWeb Request/Response APIで任意のリクエストを処理する仕組みとして説明しています。割り当てのキモは「同じ匿名IDなら、何度呼んでも同じ案になる」ハッシュ計算です。
// app/api/experiments/assign/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
type Variant = "control" | "free_plan_copy";
const EXPERIMENTS = {
pricing_page_offer_2026_06: {
cookieName: "ab_pricing_page_offer_2026_06",
variants: [
{ id: "control", weight: 50 },
{ id: "free_plan_copy", weight: 50 },
] satisfies Array<{ id: Variant; weight: number }>,
},
};
// 文字列を0〜99の数字に変換する(同じ入力なら必ず同じ数字)
function hashToBucket(input: string) {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % 100;
}
function chooseVariant(experimentId: keyof typeof EXPERIMENTS, anonymousId: string): Variant {
const experiment = EXPERIMENTS[experimentId];
const bucket = hashToBucket(`${experimentId}:${anonymousId}`);
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (bucket < cumulative) return variant.id;
}
return experiment.variants[0].id;
}
export async function GET(request: NextRequest) {
const experimentId = request.nextUrl.searchParams.get("experiment");
if (experimentId !== "pricing_page_offer_2026_06") {
return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
}
const experiment = EXPERIMENTS[experimentId];
const testAnonymousId = request.headers.get("x-test-anonymous-id");
const existingCookie = request.cookies.get(experiment.cookieName)?.value;
const anonymousId = testAnonymousId ?? existingCookie ?? crypto.randomUUID();
const variant = chooseVariant(experimentId, anonymousId);
const response = NextResponse.json({
experimentId,
variant,
anonymousId,
});
// 匿名IDをCookieに保存して、次回も同じ案を出せるようにする
response.cookies.set(experiment.cookieName, anonymousId, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
Cookieは便利ですが万能ではありません。MDNの安全なCookie設定ガイドが言うように Secure HttpOnly SameSite を適切に使う。ただし同意なしで広告・分析用の識別子として使えるとは限りません。会員SaaSならログイン後のユーザーIDをハッシュ化、匿名ブログなら期限付きCookie、広告計測ならCMPの同意状態を優先、というように用途で設計を分けます。
なお、Next.js 16では従来のMiddlewareがProxyに改名されました。エッジで全面的に書き換えるならproxy.tsドキュメントも見ておくと安全です。
「実験」と「リリース」を分けると、即ロールバックできる
A/Bテストは、実験コードを本番に入れても、フラグで露出を0%・10%・50%・100%に変えられるようにしておくと一気に安全になります。悪化したときデプロイなしで戻せるからです。VercelならVercel Flagsのような公式機能も選択肢ですが、自前で始めるなら設定ファイルに全部書き出してしまうのが分かりやすい。
# config/experiments.yaml
experiments:
pricing_page_offer_2026_06:
status: running
owner: masa
hypothesis: "Free-plan copy increases signup starts without hurting paid intent."
allocation_percent: 50
variants:
control: 50
free_plan_copy: 50
primary_metric: signup_start_rate
guardrails:
- purchase_link_click_rate
- p75_lcp_ms
- js_error_rate
rollback:
if_js_error_rate_increases_by: 0.02
if_p75_lcp_ms_worse_by_ms: 300
action: "set allocation_percent to 0 and keep logging exposure for audit"
僕が踏んだ落とし穴がまさにここでした。勝ったと思った案を、いきなり100%にした。勝ちに見えた理由が、曜日・広告流入・ニュースレター配信・検索順位の変動かもしれないのに、です。10%→50%→100%と段階的に広げ、毎段階で主要指標とガードレールの両方を確認する。そしてロールバック条件を先に文章で書いておく。これがあると、チーム内の「もう少し様子を見よう」で損失がずるずる広がるのを止められます。
SQLで勝ち負けを集計し、偽陽性を避ける
分析はエクスポージャーを起点にします。見ていないユーザーまで分母に入れると、それは実験ではなくただの全体比較です。さらに、同じユーザーが複数バリアントを見た場合は除外するか、なぜそうなったか原因を調べる。BigQueryならゼロ除算を避けるためにSAFE_DIVIDEを使うと安全です。
-- BigQuery Standard SQL
WITH exposure_raw AS (
SELECT
anonymous_id,
experiment_id,
ARRAY_AGG(variant ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS variant,
MIN(event_timestamp) AS first_exposed_at,
COUNT(DISTINCT variant) AS variant_count
FROM `project.dataset.events`
WHERE event_name = 'experiment_exposure'
AND experiment_id = 'pricing_page_offer_2026_06'
GROUP BY anonymous_id, experiment_id
),
exposure AS (
-- 複数の案を見てしまったユーザーは除外する
SELECT anonymous_id, experiment_id, variant, first_exposed_at
FROM exposure_raw
WHERE variant_count = 1
),
events_after_exposure AS (
SELECT
e.variant,
e.anonymous_id,
ev.event_name,
ev.value_usd,
ev.value_ms
FROM exposure e
LEFT JOIN `project.dataset.events` ev
ON ev.anonymous_id = e.anonymous_id
AND ev.experiment_id = e.experiment_id
AND ev.event_timestamp >= e.first_exposed_at
)
SELECT
variant,
COUNT(DISTINCT anonymous_id) AS exposed_users,
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)) AS cta_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS cta_click_rate,
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)) AS purchase_intent_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS purchase_intent_rate,
AVG(IF(event_name = 'guardrail_metric' AND value_ms IS NOT NULL, value_ms, NULL)) AS avg_guardrail_ms,
SUM(IF(event_name = 'guardrail_metric' AND value_usd IS NOT NULL, value_usd, 0)) AS revenue_proxy_usd
FROM events_after_exposure
GROUP BY variant
ORDER BY variant;
このSQLは「勝者を自動判定する魔法」ではありません。判断に必要な訪問者数(サンプルサイズ)は実験前に決める。毎日のぞいて良い数字が出た瞬間に止めると、偽陽性が増えます。これは僕の冒頭の失敗そのものでした。ほかにも、3案以上を同時に比べる、セグメントを何度も切り直す、主要指標を後から変える、広告キャンペーン開始日と重ねる、ボットを除外しない——どれも偽陽性を増やす危険な動きです。
Claude Codeには「p値が0.05未満なら勝ち」とだけ書かせないでください。「開始前の停止条件、最小サンプル、観察期間、除外ルールをMarkdownに出して」と頼むほうが、実務でははるかに使えます。
Playwrightで「土台が壊れていないか」を先に確かめる
公開前に最低限見るのは、(1) 同じ匿名IDが同じバリアントに固定されること、(2) 不明な実験IDがちゃんと失敗すること、(3) CTAが1つだけ表示されること。この3つです。Playwright公式のtestとexpectと、自動リトライされるアサーションを使います。E2Eの基礎はClaude CodeでPlaywrightテストを書く方法も参考にどうぞ。
// tests/experiments.spec.ts
import { test, expect } from "@playwright/test";
test.describe("pricing_page_offer_2026_06", () => {
test("keeps assignment stable for the same anonymous id", async ({ request, baseURL }) => {
const url = `${baseURL}/api/experiments/assign?experiment=pricing_page_offer_2026_06`;
const headers = { "x-test-anonymous-id": "demo-user-42" };
// 同じIDで2回呼んで、同じ案が返るか確認する
const first = await request.get(url, { headers });
const second = await request.get(url, { headers });
expect(first.ok()).toBeTruthy();
expect(second.ok()).toBeTruthy();
expect(await first.json()).toMatchObject(await second.json());
});
test("rejects unknown experiments", async ({ request, baseURL }) => {
const response = await request.get(`${baseURL}/api/experiments/assign?experiment=missing`);
expect(response.status()).toBe(404);
});
test("renders one monetization CTA on the pricing page", async ({ page }) => {
await page.goto("/pricing?e2e_anonymous_id=demo-user-42");
await expect(page.getByTestId("pricing-cta")).toBeVisible();
await expect(page.getByTestId("pricing-cta")).toHaveCount(1);
});
});
このテストは売上が上がることを証明しません。証明するのは「実験の土台が壊れていないこと」です。土台が傾いていたら、どれだけ高度な統計を乗せても全部砂上の楼閣になります。
プライバシーと運用で、人間が握る判断
実装前に、僕はこのチェックリストをClaude CodeにPR本文へ出させています。
- 個人情報をイベントに入れない(匿名IDだけ)
- 広告・アナリティクス・パーソナライズの同意状態を尊重する
- Cookieが拒否されたときのデフォルト表示を決めておく
- 実験ID・開始日・停止日・担当者・仮説・主要指標・ガードレール・ロールバック手順を1か所に残す
- 結果が良くても悪くても「学び」を記録する
そしてClaude Codeに任せる範囲も線を引きます。コード生成・型定義・テスト・SQLの雛形・ドキュメント更新は任せやすい。一方で、法務判断、同意バナーの要否、統計的有意性の最終判断、広告ポリシーの解釈は人間が確認する。AIが出した分析結果をそのまま公開判断に使わないことが、収益化メディアでは特に大事です。SEOとの兼ね合いはClaude CodeでSEOを最適化する方法も合わせて見ておくと、読者体験を壊さずに済みます。
よくある質問
Q. A/Bテストはどれくらいの期間回せばいいですか? 曜日の偏りをならすため最低でも1〜2週間、できれば事前に決めた最小サンプル数に達するまでです。「良い数字が出たから3日で終了」が一番危険で、僕が偽陽性で勝ちと勘違いしたのもこのパターンでした。
Q. p値が0.05未満なら本当に勝ちと判断していいですか? 主要指標で0.05未満は目安のひとつですが、それだけでは足りません。ガードレール指標が悪化していないか、観察期間と最小サンプルを満たしているか、外部要因(配信・広告・季節)が重なっていないかを必ず一緒に見ます。
Q. なぜ localStorage ではなくサーバー側で割り当てるのですか?
localStorage だと初回描画で元の案が一瞬出るちらつきが起き、別端末やプライベートブラウズで同じ人が別案を見てしまいます。Route Handler + Cookieでサーバー側に固定すれば、最初の表示から一貫した案を出せます。
Q. Claude Codeにどこまで任せていいですか? コード・型・テスト・SQL雛形・ドキュメントは任せて大丈夫です。法務判断、同意バナーの要否、有意性の最終判断、広告ポリシーの解釈は人間が握ります。AIの分析結果を無検証で公開判断に使わないこと。
Q. 小さなブログでもA/Bテストの意味はありますか? 訪問者が少ないと有意差が出にくいので、僕はクリック率のような上流の指標で、変化が大きそうな箇所(広告枠の位置、CTA文言)に絞ります。差が小さい改善を無理に検定するより、まず計測の土台を整えるほうが先です。
実際に試した結果
この設計を自分の記事CTA検証に落とし込んだとき、一番効いたのは「勝ち負けのSQL」ではなく、実験前に作ったイベント表でした。どのクリックを収益化CTAとして扱うか、広告表示速度をどこで見るか、同じ読者が複数バリアントに入ったらどうするか。これを先に決めただけで、Claude Codeへの修正依頼が半分以下になりました。
サンプル実装ではRoute Handlerの固定割り当てとPlaywrightの同一IDテストを先に通したおかげで、公開直前に localStorage 起因のちらつきを見つけ、サーバー側Cookieへ戻せました。冒頭の「勝ったつもりで100%展開して有料導線を落とした」失敗以来、僕は数字を見る前に停止条件とガードレールを先に書くようにしています。
収益化目的のA/Bテストは、派手なダッシュボードより、仮説・固定割り当て・イベントスキーマ・ガードレール・ロールバックという地味な整備で成果が決まります。Claude Codeを使うなら、画面差分を作らせる前に、実験計画・計測・検証・撤退条件まで同じPRに入れてください。社内でテンプレート化したい・伴走してほしいときは、研修・相談や教材一覧から実運用の導線に合わせて整えられます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。