Thousand APIのNext.js製フロントエンドをどう作ったか|App Router・TypeScript・Stripe連携の設計

はじめに

個人開発の MCP サーバー「Thousand API」では、ドキュメント閲覧、プラン選択、API Key の発行・管理を Web フロントエンドで担っています。MCP(Model Context Protocol)は、Claude や Cursor などの AI エージェントが外部ツールを呼び出すための仕組みです。バックエンド API と MCP 本体は別リポジトリで、フロントエンドは Next.js(App Router)+ TypeScript + Stripe で構築しました。

SaaS のフロントを App Router で作ると、次の点で迷いやすいです。

  • Server Component と Client Component の切り分け
  • Stripe Checkout への遷移と Webhook による状態同期
  • API Key など機密情報をブラウザに載せない設計
  • バックエンド API との型の共有方法

この記事では、上記 4 点を中心に Thousand API のフロントエンド構築で採用した設計を整理します。コード例は代表パターンであり、Thousand API 固有の部分と一般的な SaaS 向けパターンが混在しています。

前提: Next.js の基本操作と TypeScript の読み書きはできるが、App Router での Stripe 連携は初めて、という読者を想定しています。読了後の到達点: Stripe Checkout への遷移と Webhook 受信の最小構成を理解し、自プロジェクトに移植できる状態を目指します。

背景:フロントエンドが担う責務

Thousand API は、AI エージェント向けのユーティリティツールを MCP 経由で提供するサービスです。フロントエンドの主な役割は次のとおりです。

領域フロントの責務バックエンド側の責務(参考)
認証ログイン UI、セッション表示トークン発行・検証
課金プラン選択、Checkout への遷移サブスクリプション状態の正本
API Key一覧表示、発行・失効の操作 UIKey の生成・保存・権限チェック
ドキュメント静的・動的ページの表示ツール定義・利用制限の API

正本(Source of Truth) はバックエンドと Stripe に置きます。フロントは表示と操作の入口として機能させる構成にしました。課金状態をフロントの state だけで持つと、Webhook の遅延やタブの複数起動で不整合が起きやすくなります。

処理の流れは次のイメージです。

ユーザー
  → Next.js(App Router)
    → バックエンド API(認証・API Key)
    → Stripe Checkout(課金)
  ← Webhook → バックエンド(プラン更新)
← フロント(更新後の状態を再取得)

App Router のディレクトリ設計

App Router では、ファイル配置がそのままルーティングになります。Thousand API では次のように役割で分けました。

app/
  (marketing)/          # ランディング、料金ページ(認証不要)
  (dashboard)/          # ログイン後のダッシュボード
    layout.tsx          # 共通ナビ・認証ガード
    keys/               # API Key 管理
    billing/            # プラン・請求
  api/
    webhooks/stripe/    # Stripe Webhook(Route Handler)
lib/
  api-client.ts         # バックエンド API 呼び出し
  stripe.ts             # Stripe クライアント初期化
types/
  api.ts                # API レスポンス型
  stripe.ts             # Checkout metadata など

Route Groups(marketing) など括弧付きフォルダ)は URL に含まれません。マーケティング用レイアウトとダッシュボード用レイアウトを分けつつ、パスをシンプルに保てます(Route Groups)。

Webhook を Next.js 側に置く理由は、Stripe からの署名検証を BFF(Backend for Frontend)層で行い、検証済みイベントだけを内部 API へ中継するためです。バックエンドを直接公開せず、イベント入口を 1 箇所にまとめられます。

実装時の注意点:

  • 認証が必要なページは (dashboard)/layout.tsx でセッションを確認し、未ログインならログインページへリダイレクトする
  • Stripe の Webhook は app/api/webhooks/stripe/route.ts に置く
  • App Router では request.text() で生ボディを取得する。Pages Router の bodyParser: false は不要
  • 環境変数は NEXT_PUBLIC_ 付きのみクライアントへ露出する。Stripe のシークレットキーはサーバー側だけで使う

ローカル開発で必要になる主な環境変数は次のとおりです。

変数名用途
STRIPE_SECRET_KEYCheckout 作成・Webhook 検証
STRIPE_WEBHOOK_SECRETWebhook 署名検証(CLI / 本番で別値)
STRIPE_PRICE_PRO などプランごとの Price ID
API_BASE_URLバックエンド API のベース URL
INTERNAL_API_TOKENWebhook 中継用の内部認証
APP_URLCheckout の success / cancel URL 用

Server Component と Client Component の切り分け

App Router では、デフォルトが Server Component です。次の基準で分けました。

役割コンポーネント種別
データ取得・一覧表示Server ComponentAPI Key 一覧、現在のプラン表示
ボタン・フォーム・モーダルClient ComponentCheckout 申込、Key 発行ダイアログ
機密処理Server Action / Route HandlerCheckout 作成、Webhook 受信

Server Action は、サーバー上で実行される関数をフォームから直接呼び出せる App Router の仕組みです。Stripe のシークレットキーを使う処理は Server Action か Route Handler に閉じ込めます。

認証ガードの最小例です。

// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
  if (!session) {
    redirect("/login");
  }
  return <>{children}</>;
}

TypeScript の型設計

フロントとバックエンドが別リポジトリだと、型を自動生成で共有できないことが多いです。その場合でも API レスポンス型を types/api.ts に集約すると、画面コンポーネントの props が読みやすくなります。

// types/api.ts
export type PlanId = "free" | "pro" | "team";

export type Subscription = {
  planId: PlanId;
  status: "active" | "canceled" | "past_due" | "trialing";
  currentPeriodEnd: string; // ISO 8601
};

export type ApiKey = {
  id: string;
  name: string;
  prefix: string; // 表示用(例: "tsk_abc...")
  createdAt: string;
  lastUsedAt: string | null;
};

export type ApiError = {
  code: string;
  message: string;
};

fetch のラッパーでレスポンスを型付けすると、エラー処理も一箇所にまとまります。Cookie ベースのセッション認証を想定し、credentials: "include" を付けています。

// lib/api-client.ts
import type { ApiError } from "@/types/api";

export async function apiFetch<T>(
  path: string,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(`${process.env.API_BASE_URL}${path}`, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...init?.headers,
    },
    credentials: "include",
  });

  if (!res.ok) {
    const body = (await res.json()) as ApiError;
    throw new Error(body.message ?? `API error: ${res.status}`);
  }

  return res.json() as Promise<T>;
}

Stripe の session.metadata{ [key: string]: string } 型です。アプリ固有のフィールドを扱うときは、専用型を定義してからキャストします。

// types/stripe.ts
export type CheckoutMetadata = {
  userId: string;
  planId: string;
};

// Server Action 内での利用例
import type Stripe from "stripe";

function readMetadata(session: Stripe.Checkout.Session): CheckoutMetadata {
  const raw = session.metadata ?? {};
  if (!raw.userId || !raw.planId) {
    throw new Error("Missing metadata on Checkout Session");
  }
  return { userId: raw.userId, planId: raw.planId };
}

Stripe サブスクリプション連携

課金フローは Stripe Checkout でセッションを作成し、完了後は Webhook でバックエンドのプラン状態を更新する形にしました。フロントは Checkout URL へリダイレクトするだけです。

Checkout Session の作成(Server Action)

// app/actions/billing.ts
"use server";

import Stripe from "stripe";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createCheckoutSession(planId: string) {
  const session = await getSession();
  if (!session?.userId) {
    throw new Error("Unauthorized");
  }

  const priceId = process.env[`STRIPE_PRICE_${planId.toUpperCase()}`];
  if (!priceId) {
    throw new Error(`Unknown plan: ${planId}`);
  }

  const checkout = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing?success=1`,
    cancel_url: `${process.env.APP_URL}/billing?canceled=1`,
    metadata: { userId: session.userId, planId },
    client_reference_id: session.userId,
  });

  if (!checkout.url) {
    throw new Error("Checkout session URL is missing");
  }

  redirect(checkout.url);
}

Client Component からは Server Action をボタン経由で呼び出します。userId は Server Action 内でセッションから取得し、Client から渡さない構成にしてください。

// app/(dashboard)/billing/upgrade-button.tsx
"use client";

import { createCheckoutSession } from "@/app/actions/billing";

export function UpgradeButton({ planId }: { planId: string }) {
  return (
    <form action={createCheckoutSession.bind(null, planId)}>
      <button type="submit">Pro プランに申し込む</button>
    </form>
  );
}

Checkout 成功後の状態更新

success_url で戻った直後は、Webhook の処理が完了していないことがあります。Server Component でプランを取得し、未反映のときはメッセージを出すパターンが扱いやすいです。

// app/(dashboard)/billing/page.tsx(抜粋)
import { apiFetch } from "@/lib/api-client";
import type { Subscription } from "@/types/api";

export default async function BillingPage({
  searchParams,
}: {
  searchParams: { success?: string };
}) {
  const subscription = await apiFetch<Subscription>("/me/subscription");

  return (
    <div>
      <p>現在のプラン: {subscription.planId}</p>
      {searchParams.success === "1" && subscription.planId === "free" && (
        <p>決済を受け付けました。プラン反映まで数分かかる場合があります。</p>
      )}
    </div>
  );
}

反映を早く見せたい場合は、短い間隔で再取得する Client Component を足すか、router.refresh() で Server Component を再実行します。

Webhook(Route Handler)

Stripe は受信したバイト列そのもので署名します。App Router では await request.text() で生の文字列を取得し、constructEvent に渡します。request.json() を使うと署名検証が失敗します(Stripe Webhook 署名)。

// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "No signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json(
      { error: `Signature verification failed: ${message}` },
      { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed":
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      await fetch(`${process.env.API_BASE_URL}/internal/stripe-events`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
        },
        body: JSON.stringify({ type: event.type, data: event.data.object }),
      });
      break;
    default:
      break;
  }

  return NextResponse.json({ received: true });
}

実装時の注意点:

  • ローカル開発では Stripe CLIstripe listen --forward-to localhost:3000/api/webhooks/stripe で転送する
  • CLI が表示する whsec_....env.localSTRIPE_WEBHOOK_SECRET に設定する(Next.js の Stripe 例
  • 本番とローカルで Webhook シークレットは別物になる
  • 同じ event.id の再送に備え、バックエンド側で処理済み ID を記録して冪等にする

よくある失敗例:Webhook で request.json() を使う

App Router の Route Handler で次のように書くと、Stripe の署名検証が失敗します。

// 誤り:JSON パース後のボディでは署名が一致しない
export async function POST(request: Request) {
  const body = await request.json();
  const event = stripe.webhooks.constructEvent(
    JSON.stringify(body),
    request.headers.get("stripe-signature")!,
    webhookSecret
  );
  // ...
}

JSON.stringify で戻しても、元のバイト列と一致しない場合があります。

対処: await request.text() で受け取った文字列をそのまま constructEvent に渡す。Pages Router 時代の export const config = { api: { bodyParser: false } } は App Router では不要です。

API Key 発行フロー

API Key の 生成と保存 はバックエンドのみで行い、フロントには 作成直後の一度だけ 全文を返すパターンにしました。一覧画面では prefix のみ表示します。

1. ユーザーが「キーを発行」をクリック
2. フロント → POST /api/keys(バックエンド)
3. バックエンドがランダムな Key を生成・ハッシュ保存
4. レスポンスで平文 Key を一度だけ返す
5. フロントがモーダルで表示(コピー促し・再表示不可の警告)
6. 以降の一覧は prefix のみ
// 発行レスポンス型(types/api.ts に追加)
export type CreateApiKeyResponse = {
  key: ApiKey;
  secret: string; // このレスポンスでのみ平文が返る
};

実装時の注意点:

  • 平文 Key を localStorage に保存しない。ユーザーがコピーできなければ再発行してもらう
  • 失効(revoke)は論理削除または即時無効化とし、MCP 側の認証ミドルウェアと整合させる
  • Server Component で Key 一覧を取得し、発行ボタンとモーダルだけ Client Component にする

まとめ

  • SaaS フロントは課金状態の正本を Stripe + バックエンドに置き、Next.js は表示と操作の入口に徹する
  • Server Component でデータ取得、Client Component で操作 UI、Stripe 処理は Server Action / Route Handler に閉じる
  • Checkout 成功直後は Webhook 遅延を想定し、プラン表示に待ちメッセージや再取得を用意する
  • API Key はバックエンドで生成し、平文は発行直後の一度だけユーザーに見せる

次に試すこと

  1. Next.js の Stripe TypeScript 例をクローンし、Checkout と Webhook の最小構成を動かす
  2. Stripe CLI でローカル Webhook を転送し、checkout.session.completed が Route Handler に届くことを確認する
  3. success_url 戻り後のプラン表示に、Webhook 遅延を考慮したメッセージを足す
  4. API Key 画面では一覧(prefix のみ)と発行(平文一度表示・再表示不可)の UI を分ける

Source