この記事で書くこと
AI 機能を売りにするサービスの LP に「登録不要で触れるデモ」を置くケースが増えました。動くものを見せられるのは強い一方で、これは自分の API キーの裏で、インターネットの誰でも叩ける口を開けるということでもあります。
放置すると、課金の暴走・悪用・パース事故が全部自分持ちになります。しかも防御を固めても、最後に「計測が動いていなくて何も学べない」という別の落とし穴が待っていました(後述。5日分のデータを失いました)。
個人開発者が最小工数で、登録不要の AI デモを安全に公開するには何をすべきか。自分が自社プロダクト(SNS 投稿ジェネレーターの試し起動デモ)を Vercel で本番公開したときの実装を、実コードと失敗込みで書きます。
対象の構成は Next.js(App Router)+ @anthropic-ai/sdk + Vercel です。
全体像
デモの中身は単純で、「トピックを1つ入れると、SNS 投稿の文案が3案返る」だけの1画面です。
ブラウザ → POST /api/generate(Next.js Route Handler)
├─ レート制限(IP ベース)
├─ 入力バリデーション(200字上限)
├─ generatePosts() … Haiku → Opus フォールバック
└─ 投稿3案の JSON を返す
ポイントは、クライアントには生成結果しか渡さないことです。API キーはサーバ側の環境変数のみ、モデル呼び出しはすべて Route Handler の中で完結させます。ブラウザから Anthropic API を直接叩く構成は、キーが漏れるのでデモでは選択肢になりません。
防御1:フォールバックチェーンを「コストファースト」で組む
デモは失敗するとそこで離脱されるので、可用性は欲しい。でも公開デモに高いモデルを常用すると、課金が読めなくなります。
そこで、一次を安いモデル(Haiku)、二次を品質側のモデル(Opus)にした直列フォールバックにしました。普段の実行はほぼ全部 Haiku で終わり、Haiku が落ちたときだけ Opus に切り替わります。
// フォールバックチェーン(cost-first → quality)。
const MODEL_CHAIN = ["claude-haiku-4-5", "claude-opus-4-8"] as const;
export async function generatePosts(topic: string): Promise<string[]> {
let lastErr: unknown;
for (const model of MODEL_CHAIN) {
try {
const posts = await tryModel(model, topic);
if (posts.length > 0) return posts;
lastErr = new Error(`${model}: empty result`);
} catch (err) {
lastErr = err;
console.warn(
`[ai] ${model} failed, falling back:`,
err instanceof Error ? err.message : err,
);
}
}
throw new AllProvidersFailedError(
lastErr instanceof Error ? lastErr.message : "all models failed",
);
}
順番が重要です。「品質の高いモデルを一次にして、落ちたら安いモデルへ」だと、平常時のコストが最大化されます。公開デモは逆にして、平常時は安いモデルで回し、高いモデルは異常時の保険に置きます。生成品質は用途(短文の投稿案)に対して Haiku で十分でした。ここは用途次第なので、一次モデルの出力を目視で数十件確認してから決めています。
もう1つ、posts.length > 0 の空チェックを成功判定に入れています。API 呼び出し自体は成功したのに中身が空、というケースもフォールバック対象にしないと、ユーザーには「無反応のデモ」に見えます。
防御2:構造化出力でパース事故を潰す
生成結果を「投稿3案の配列」としてUIに流すので、モデルの出力は JSON で受けたい。プロンプトで「JSON で返して」と頼む方式は、前置きの文章が混ざったりコードフェンスで包まれたりして、確率的に壊れます。
Claude API には出力スキーマを強制する仕組み(output_config.format の json_schema)があるので、これを使いました。
const POSTS_SCHEMA = {
type: "object",
properties: {
posts: {
type: "array",
items: { type: "string" },
},
},
required: ["posts"],
additionalProperties: false,
} as const;
const response = await client.messages.create({
model,
max_tokens: 1500,
output_config: {
format: { type: "json_schema", schema: POSTS_SCHEMA },
},
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userPrompt }],
});
これで JSON.parse が事実上安全になります。仮に壊れても、防御1のフォールバックが「パース失敗=そのモデルの失敗」として次のモデルに回すので、ユーザーにパースエラーがそのまま見えることはありません。
防御3:レート制限は「正直な割り切り」で入れる
登録不要デモの最大のリスクは、スクリプトで無限に叩かれることです。IP ベースの簡易レート制限(1時間10回)を Route Handler に入れました。
// 簡易レート制限(インメモリ・1インスタンス内のみ。本番はKV/Redisに置換)。
const RATE: Map<string, { count: number; resetAt: number }> = new Map();
const LIMIT_PER_HOUR = 10;
function rateLimited(ip: string): boolean {
const now = Date.now();
const slot = RATE.get(ip);
if (!slot || now > slot.resetAt) {
RATE.set(ip, { count: 1, resetAt: now + 3600_000 });
return false;
}
if (slot.count >= LIMIT_PER_HOUR) return true;
slot.count += 1;
return false;
}
正直に書くと、これは完全な防御ではありません。インメモリなので、Vercel のサーバレス環境ではインスタンスが分かれれば別カウントになりますし、コールドスタートでリセットもされます。厳密にやるなら KV や Redis(Upstash など)に置くべきで、コード内のコメントにもそう残してあります。
それでも入れる価値はあります。厳密に10回を守りたいわけではなくて、ループで数千回叩かれる事故が止まればいい。多少ザルでも、単純なスクリプトの連打はこれで止まります。検証フェーズのデモに Redis を立てるのは過剰装備だと判断しました。
同じ発想で、上限は他にも重ねています。
- 入力トピックは 200字で切る(
String(body.topic ?? "").slice(0, 200))。長文を流し込まれても入力トークンが暴れない -
max_tokens: 1500。出力側の上限も固定 - 429 と 502 のエラーメッセージはユーザー向けの日本語のみ。内部のエラー詳細はログに出し、レスポンスには載せない
1回あたりの消費が「入力200字+出力1500トークン、ほぼ Haiku」に固定されるので、最悪ケースの課金額が暗算できるようになります。ここが読めていれば、公開ボタンを押すときの不安がだいぶ減ります。
防御4:エラーの出し分け
細かい点ですが、失敗時のレスポンスは3種類に分けました。
| 状況 | ステータス | ユーザーに見せる文言 |
|---|---|---|
| レート超過 | 429 | お試しは1時間に10回までです |
| 入力不正 | 400 | トピックを入力してください |
| 全モデル失敗 | 502 | ただいま混み合っています |
「全モデル失敗」を 500 系の生エラーで返すと、デモとしては事故に見えます。原因はサーバログに残しつつ(console.error。ただし入力内容などは出さない)、表側は「混み合っています」で受け流す。デモの失敗画面は商品の第一印象なので、ここだけは UI の一部として扱いました。
落とし穴:防御は固めたのに、計測が動いていなかった
ここからが本題かもしれません。公開して5日後に気づいたのですが、Vercel Analytics のデータが1件も収集されていませんでした。
状況はこうです。
- コード側は
@vercel/analyticsを導入済み。track()でカスタムイベント(デモ実行・CTA クリック)も配線済み - デプロイ後、本番で計測スクリプト(
/_vercel/insights/script.js)が 200 で配信されていることも確認済み - コンソールエラーも 0。ここまで見て「計測は動いている」と判断した
ところが、Vercel Analytics はダッシュボード側で有効化して初めて収集が始まる仕様でした。プロジェクトの Analytics タブでの有効化が漏れており、スクリプトは配信されているのにデータは溜まっていない状態が5日間続きました。遡及はできないので、この間の流入データは消失です。告知直後の一番知りたい期間の数字が、丸ごとありません。
以後、計測の動作確認はダッシュボードに実データが1件現れるまでやることにしました。デプロイ後に自分で1回アクセスして、Visitors が 1 になるのを見て初めて完了扱いにする。スクリプトの 200 配信確認は、動作確認のうちに入っていませんでした。
もう1つ細かい注意として、この確認を自動ブラウザ(Playwright 等)でやろうとすると、計測スクリプト側のボット検出(navigator.webdriver 判定)で除外されて「動いていないように見える」ことがあります。動作確認の1アクセスは人間のブラウザでやってください。
まとめ
登録不要の AI デモを公開するときにやったことの一覧です。
- API キーはサーバ側のみ。モデル呼び出しは Route Handler で完結
- フォールバックチェーンはコストファースト(安いモデルが一次、高いモデルは保険)。空レスポンスもフォールバック対象にする
- 出力は
json_schemaの構造化出力で受け、パース事故を仕組みで潰す - レート制限+入力200字+
max_tokens固定で、最悪ケースの課金額を暗算できる状態にする - エラーはユーザー向け文言と内部ログを分離する
- そして計測は、ダッシュボードに実データが現れるまで動作確認を終わらせない
3〜4 は厳密さを捨てて「事故の上限を固定する」ことを優先した割り切りです。検証フェーズの個人開発なら、どこで割り切ったかを自分で説明できる状態にしておけば十分だと考えています。
このデモは実際に公開していて、登録不要でブラウザから動かせます: https://runstack-phase0.vercel.app/
なぜ「zip 売り」ではなくホスト実行のデモという形にしたのか、という事業側の話は Zenn に別記事として書いています。興味があればそちらもどうぞ: https://zenn.dev/ponfreelance/articles/runstack-usage-based-pivot