Tips & Tricks (更新: 2026/6/2)

Claude CodeでA/Bテストを実装する方法: SaaSとブログ収益化の実践ガイド

Claude Codeで仮説設計、割り当て、計測、SQL分析、ロールバックまで安全に作るA/Bテスト実装ガイド。

Claude CodeでA/Bテストを実装する方法: SaaSとブログ収益化の実践ガイド

最初に決めるのはコードではなく仮説

A/Bテストは「2つの画面をランダムに出す仕組み」ではありません。SaaSの無料登録、ブログの広告クリック、資料請求、価格ページの購入導線など、収益に近い行動を改善するための小さな実験です。Claude Codeにいきなり「A/Bテストを作って」と頼むと、表示切り替えのコードだけはすぐ出ます。しかし、仮説、計測イベント、サンプルサイズ、除外条件、戻し方まで決めていない実験は、数字が良く見えても意思決定に使えません。

この記事では、Claude Codeを「実装係」ではなく「実験設計をコードに落とす相棒」として使います。専門用語も先に言い換えます。バリアントは「比較する案」、エクスポージャーは「ユーザーがどの案を見たかを初めて記録すること」、ガードレール指標は「改善してはいけない安全確認の数字」、偽陽性は「偶然のブレを勝ちだと勘違いすること」です。

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_clickctaClickedsignup_click のように散らばると、集計時に人間が補正することになります。Claude CodeにはUIより先に型付きイベントスキーマを作らせます。

Google Analyticsを使う場合、イベントとパラメータの考え方はGoogle Analytics 4のイベント資料Google tagのパラメータリファレンスを確認してください。カスタムパラメータを後でレポートに出すには、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,
  });
}

このコードは「何を送るか」を型で縛るための最小例です。メールアドレス、氏名、会社名などの個人情報をイベントに入れないでください。広告やアナリティクスに関する同意が必要な地域では、Googleの同意モード資料のように、タグ送信より前に同意状態を初期化します。

Next.js Route Handlerでサーバー側に割り当てる

ブラウザの localStorage だけで割り当てると、初回表示でコントロール案が一瞬出てから別案に切り替わるちらつきが起きます。また、ログイン前後、別端末、プライベートブラウズ、ブラウザのストレージ制限で同じ人が別バリアントを見ることがあります。MDNは localStorage を同一オリジンに保存され、ブラウザセッションをまたいで残るストレージとして説明していますが、これはサーバー初回描画の割り当てには向きません。参考: MDN localStorage

Next.jsでは、まずRoute Handlerでサーバー側に割り当てるのが扱いやすいです。公式のroute.js/route.tsドキュメントはRoute HandlerをWeb Request/Response APIで任意のリクエストを処理する仕組みとして説明しています。Cookieの読み書きにはNextResponseを使えます。なお、Next.js 16では従来のMiddlewareがProxyに改名されたため、エッジで全面的に書き換える場合はproxy.jsドキュメントも確認してください。

// 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 }>,
  },
};

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,
  });

  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設定ガイドが説明するように、SecureHttpOnlySameSite を適切に使います。ただし、同意なしで広告・分析用の識別子として使えるとは限りません。会員SaaSではログイン後のユーザーIDをハッシュ化して使う、匿名ブログでは期限付きCookieにする、広告計測ではCMPの同意状態を優先する、というように用途ごとに設計を分けます。

フィーチャーフラグでロールアウトとロールバックを分ける

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で割り当てと表示を検証する

公開前に最低限見るべきことは、同じ匿名IDが同じバリアントに固定されること、不明な実験IDが失敗すること、CTAが1つだけ表示されることです。Playwrightの公式ドキュメントではtestexpect、自動リトライされるアサーションが説明されています。

// 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" };

    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本文へ出させます。まず、個人情報をイベントに入れない。次に、広告・アナリティクス・パーソナライズの同意状態を尊重する。第三に、Cookieが拒否された時のデフォルト表示を決める。第四に、実験ID、開始日、停止日、担当者、仮説、主要指標、ガードレール、ロールバック手順を1か所に残す。第五に、結果が良くても悪くても学びを記録する。

ブログ収益化では、読者体験を壊すテストが短期収益を上げることがあります。広告を増やせばクリックや表示回数は伸びるかもしれませんが、読了率、検索流入、再訪率が落ちれば長期では損です。SaaSでも同じです。無料登録を増やす文言が、実は有料転換しにくいユーザーだけを増やすことがあります。だから主要指標とガードレールをセットで見ます。

Claude Codeに任せる範囲も決めます。コード生成、型定義、テスト、SQLの雛形、ドキュメント更新は任せやすい領域です。一方で、法務判断、同意バナーの要否、統計的有意性の最終判断、広告ポリシーの解釈は人間が確認します。AIが作った分析結果をそのまま公開判断に使わないことが、収益化メディアでは特に重要です。

この記事で紹介した内容を実際に試した結果

Masaがこの設計を記事CTAの検証手順に落とし込んだとき、一番効果があったのは「勝ち負けのSQL」ではなく、実験前のイベント表でした。どのクリックを収益化CTAとして扱うか、広告表示速度をどこで見るか、同じ読者が複数バリアントに入ったらどうするかを先に決めたことで、Claude Codeへの修正依頼が半分以下になりました。サンプル実装ではRoute Handlerの固定割り当てとPlaywrightの同一IDテストを先に通したため、公開直前にlocalStorage起因のちらつきを見つけてサーバー側Cookieへ戻せました。

収益化目的のA/Bテストは、派手なダッシュボードよりも、仮説、固定割り当て、イベントスキーマ、ガードレール、ロールバックの地味な整備が成果を決めます。Claude Codeを使うなら、画面差分を作らせる前に、実験計画、計測、検証、撤退条件まで同じPRに入れてください。相談やテンプレート化が必要な場合はClaude Codeトレーニングプロダクト一覧から、実運用の導線に合わせて整備できます。

#Claude Code #A/Bテスト #SaaS #ブログ収益化 #Next.js #分析
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。