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 | 一覧表示、発行・失効の操作 UI | Key の生成・保存・権限チェック |
| ドキュメント | 静的・動的ページの表示 | ツール定義・利用制限の 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_KEY | Checkout 作成・Webhook 検証 |
STRIPE_WEBHOOK_SECRET | Webhook 署名検証(CLI / 本番で別値) |
STRIPE_PRICE_PRO など | プランごとの Price ID |
API_BASE_URL | バックエンド API のベース URL |
INTERNAL_API_TOKEN | Webhook 中継用の内部認証 |
APP_URL | Checkout の success / cancel URL 用 |
Server Component と Client Component の切り分け
App Router では、デフォルトが Server Component です。次の基準で分けました。
| 役割 | コンポーネント種別 | 例 |
|---|---|---|
| データ取得・一覧表示 | Server Component | API Key 一覧、現在のプラン表示 |
| ボタン・フォーム・モーダル | Client Component | Checkout 申込、Key 発行ダイアログ |
| 機密処理 | Server Action / Route Handler | Checkout 作成、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 CLI の
stripe listen --forward-to localhost:3000/api/webhooks/stripeで転送する - CLI が表示する
whsec_...を.env.localのSTRIPE_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 はバックエンドで生成し、平文は発行直後の一度だけユーザーに見せる
次に試すこと
- Next.js の Stripe TypeScript 例をクローンし、Checkout と Webhook の最小構成を動かす
- Stripe CLI でローカル Webhook を転送し、
checkout.session.completedが Route Handler に届くことを確認する success_url戻り後のプラン表示に、Webhook 遅延を考慮したメッセージを足す- API Key 画面では一覧(prefix のみ)と発行(平文一度表示・再表示不可)の UI を分ける





