Next.js のチャットボットで複数のモデルプロバイダーが必要な場合、最も有用なバックエンドは、認証を一元化しつつ、リクエストごとにアプリケーションがモデルを選べる方式です。CometAPI はこのパターンを提供します。サーバーは 1 つの API キーと OpenAI 互換のベース URL https://api.cometapi.com/v1 を保持し、リクエストの model フィールドが利用可能な GPT、Claude、Gemini などのチャットモデルを選択します。
このチュートリアルでは、動作する App Router プロジェクトを構築します。サーバー側のモデル許可リスト、ストリーミング Route Handler、ブラウザのモデルセレクター、保護された環境変数、完全なチャット UI、デプロイ手順を含みます。CometAPI のキーはブラウザに渡されません。
マルチモデル AI チャットアプリとは?
マルチモデル AI チャットのアーキテクチャでは、ユーザーインターフェースと複数のモデルプロバイダーの間に、アプリケーションが制御する 1 つのバックエンドを置きます。本プロジェクトでは、ブラウザは会話を /api/chat に送信し、サーバーは CometAPI の認証情報を秘匿したまま、要求されたモデル ID を検証し、互換 API を通じてリクエストを転送します。
Next.js でのモデル切替はどう機能する?
モデルの切替はルーティングの意思決定です。ブラウザは承認済みのモデル ID を会話と一緒に /api/chat へ送ります。Route Handler は CometAPI の認証情報を付与し、POST /v1/chat/completions を呼び出し、選択されたモデルのレスポンスをストリームでブラウザへ返します。
共通のエンドポイントであっても、すべてのモデルが同一になるわけではありません。出力スタイル、ツールの挙動、サポートするパラメータ、コンテキスト上限、料金は異なり得ます。モデル ID はサーバー側の許可リストで管理し、ユーザーへ公開する前に、同一のアプリケーションプロンプトで各ルートをテストしてください。
何を作るか
完成したアプリケーションは 1 つの Next.js バックエンドと 3 つの選択可能なチャットルートを持ちます。
| Model ID | 例示的な役割 | CometAPI 価格 |
|---|---|---|
| gemini-3.7-flash | コストを重視した高ボリュームのチャット | $0.60 input / $3.00 output |
| claude-opus-5 | 集中したプレミアム推論 | $4 input / $20 output |
| gpt-5.6 | gpt-5.6 は GPT-5.6 向けの汎用 CometAPI ルートで、現在は Sol ティアにマッピングされています。 | $3.2 input / $16 output(ショートコンテキスト層) |
価格に関する注記: 以下の価格は 100 万トークンあたりの USD で、2026 年 8 月 21 日時点で確認したものであり、変更される可能性があります。運用前に必ずモデルページで最新の料金を確認してください。表はルーティングの一例であり、品質のランキングではありません。GPT-5.6 は 272,000 トークンを超えるとより高い価格ティアが適用されます。ここで使用した日付付きの料金は、リンク先の Gemini 3.7 Flash、Claude Opus 5、GPT-5.6 のモデルページを参照してください。
CometAPI は各プロバイダーの公式 API に対して透明性のある料金設定を行います。OpenAI、Claude、Gemini など公式の統一価格があるモデルは、公式価格に対して 0.8:1 の比率(20% 割引)でトークン課金されます。公式 API がないモデル(MidJourney、Kling、Luma)は、CometAPI が設定するコールあたりの料金で課金され、同様に 20% 割引です。マークアップの算出式、モデルごとの料金、課金単位は CometAPI Pricing Guide を参照してください。
この 3 つのルートは異なるトレードオフをカバーします。Gemini 3.7 Flash は Google の効率的なエージェント型ワークホースで、マルチモーダル入力と 1,048,576 トークンのコンテキストウィンドウを備え、高ボリュームのチャット、コーディング、ナレッジワークフローに適しています。Claude Opus 5 は Anthropic のフロンティア推論モデルで、多段の分析、コード、丁寧な長文作成に強みがあり、プレミアム価格です。GPT-5.6 は OpenAI の汎用モデルで、広いコンテキストウィンドウのもと、推論・ツール利用・下書き生成のバランスが良く、日常的なプロダクション・トラフィックに適します。
準備するもの
Node.js、npm、CometAPI アカウント、および CometAPI Quick Start から取得したサーバー側 API キーが必要です。上記 3 つの正確なモデル ID は 2026 年 8 月 21 日のライブカタログで利用可能で、「upcoming」マークがなく、POST /v1/chat/completions を公開していました。
このチュートリアルでは次の共通設定を使用します。
- API キー:
COMETAPI_API_KEY - ベース URL:
https://api.cometapi.com/v1 - カタログエンドポイント:
GEThttps://api.cometapi.com/api/models
Step 1: Next.js アプリを作成する
npx create-next-app@latest multi-model-chat --ts --app --eslintcd multi-model-chatnpm run dev
このバージョンではモデルプロバイダーの SDK は不要です。サーバーは組み込みの fetch API を使用し、上流の Server-Sent Events のストリームを転送します。
Step 2: CometAPI キーをサーバーに保持する
プロジェクトルートに .env.local を作成します。
COMETAPI_API_KEY=replace_with_your_cometapi_keyCOMETAPI_BASE_URL=https://api.cometapi.com/v1
キーに NEXT_PUBLIC_ プレフィックスを付けないでください。Next.js はそのプレフィックスが付いた変数のみをブラウザバンドルに公開します。API キーはサーバー側の Route Handler 内に置くべきです。
Step 3: モデルポリシーを定義する
許可された ID はフロントのドロップダウンだけでなくバックエンドにも配置します。ユーザーはブラウザのコントロールを迂回してルートを直接呼び出せるため、サーバーは未知のモデル値を拒否する必要があります。
const ALLOWED_MODELS = [ "gemini-3.7-flash", "claude-opus-5", "gpt-5.6",] as const;type ModelId = (typeof ALLOWED_MODELS)[number];
クライアントも同じ 3 つの ID をセレクターに使用しますが、真のソースはサーバー側です。
Step 4: Route Handler で CometAPI をストリーム転送する
app/api/chat/route.ts を作成します。このルートはテキストのみのチャットペイロードを検証し、stream: true で CometAPI を呼び出し、認証情報を露出させずに上流のイベントストリームを返します。
export const runtime = "nodejs";export const dynamic = "force-dynamic";const ALLOWED_MODELS = [ "gemini-3.7-flash", "claude-opus-5", "gpt-5.6",] as const;type ModelId = (typeof ALLOWED_MODELS)[number];type ChatMessage = { role: "system" | "user" | "assistant"; content: string;};function isModelId(value: unknown): value is ModelId { return ( typeof value === "string" && (ALLOWED_MODELS as readonly string[]).includes(value) );}function isChatMessage(value: unknown): value is ChatMessage { if (typeof value !== "object" || value === null) return false; const message = value as Record<string, unknown>; return ( ["system", "user", "assistant"].includes(String(message.role)) && typeof message.content === "string" && message.content.length > 0 && message.content.length <= 20_000 );}export async function POST(request: Request) { const apiKey = process.env.COMETAPI_API_KEY; const baseUrl = ( process.env.COMETAPI_BASE_URL || "https://api.cometapi.com/v1" ).replace(/\/$/, ""); if (!apiKey) { return Response.json( { error: "COMETAPI_API_KEY が設定されていません。" }, { status: 500 }, ); } let payload: { model?: unknown; messages?: unknown }; try { payload = await request.json(); } catch { return Response.json({ error: "JSON ボディが不正です。" }, { status: 400 }); } if (!isModelId(payload.model)) { return Response.json({ error: "サポートされていないモデル ID です。" }, { status: 400 }); } if ( !Array.isArray(payload.messages) || payload.messages.length === 0 || payload.messages.length > 50 || !payload.messages.every(isChatMessage) ) { return Response.json( { error: "messages には 1 ~ 50 件の有効なテキストメッセージが必要です。" }, { status: 400 }, ); } const upstream = await fetch(`${baseUrl}/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: payload.model, messages: payload.messages, stream: true, }), cache: "no-store", signal: request.signal, }); if (!upstream.ok) { const requestId = upstream.headers.get("x-request-id"); console.error("CometAPI request failed", { status: upstream.status, requestId, }); return Response.json( { error: "選択したモデルのリクエストに失敗しました。", status: upstream.status, requestId, }, { status: upstream.status }, ); } if (!upstream.body) { return Response.json( { error: "モデルがレスポンスボディを返しませんでした。" }, { status: 502 }, ); } return new Response(upstream.body, { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", }, });}
ハンドラーはブラウザの切断シグナルを上流へ渡すため、リクエストを閉じることで不要な生成を停止できます。また、診断の詳細はサーバーログに残しつつ、クライアントにはサニタイズしたエラーを返します。
Step 5: モデル選択とストリーム解析を追加する
app/page.tsx にクライアントコンポーネントを作成します。選択されたモデルを会話とともに送信し、各 data: イベントを解析して、delta.content の増分テキストを最後のアシスタントメッセージに追加します。
ブラウザが直接 CometAPI を呼ぶことはありません。唯一の送信先はあなたの /api/chat ルートであり、ここでキーを秘匿し、許可リストを強制します。
コアプロジェクトコード
上記の Route Handler が app/api/chat/route.ts の完全版です。以下のページ、レイアウト、スタイルシートを追加して、実行可能なプロジェクトを完成させましょう。
app/page.tsx
"use client";import { FormEvent, useState } from "react";const MODEL_OPTIONS = [ { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" }, { id: "claude-opus-5", label: "Claude Opus 5" }, { id: "gpt-5.6", label: "GPT-5.6" },] as const;type Role = "user" | "assistant";type Message = { role: Role; content: string };function textFromSseLine(line: string): string { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return ""; const data = trimmed.slice(5).trim(); if (!data || data === "[DONE]") return ""; try { const event = JSON.parse(data); return event.choices?.[0]?.delta?.content ?? ""; } catch { return ""; }}export default function Home() { const [model, setModel] = useState("gemini-3.7-flash"); const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); function appendAssistantText(text: string) { if (!text) return; setMessages((current) => { const next = [...current]; const lastIndex = next.length - 1; if (lastIndex >= 0 && next[lastIndex].role === "assistant") { next[lastIndex] = { ...next[lastIndex], content: next[lastIndex].content + text, }; } return next; }); } async function sendMessage(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const content = input.trim(); if (!content || loading) return; const outgoing: Message[] = [...messages, { role: "user", content }]; setMessages([...outgoing, { role: "assistant", content: "" }]); setInput(""); setError(""); setLoading(true); try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, messages: outgoing }), }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `リクエストが失敗しました(${response.status})`); } if (!response.body) throw new Error("ストリーミングを利用できません。"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { appendAssistantText(textFromSseLine(line)); } if (done) { appendAssistantText(textFromSseLine(buffer)); break; } } } catch (requestError) { setError( requestError instanceof Error ? requestError.message : "リクエストに失敗しました。", ); } finally { setLoading(false); } } return ( <main className="shell"> <section className="chat"> <header> <p className="eyebrow">Next.js + CometAPI</p> <h1>マルチモデルチャット</h1> <label> モデル <select value={model} onChange={(event) => setModel(event.target.value)} disabled={loading} > {MODEL_OPTIONS.map((option) => ( <option key={option.id} value={option.id}> {option.label} </option> ))} </select> </label> </header> <div className="messages" aria-live="polite"> {messages.length === 0 ? ( <p className="empty">モデルを選んでメッセージを送信してください。</p> ) : ( messages.map((message, index) => ( <article className={message.role} key={`${message.role}-${index}`}> <b>{message.role === "user" ? "あなた" : "アシスタント"}</b> <p>{message.content || "…"}</p> </article> )) )} </div> <form onSubmit={sendMessage}> <textarea value={input} onChange={(event) => setInput(event.target.value)} placeholder="質問を入力…" rows={3} maxLength={20_000} /> <button disabled={loading || !input.trim()} type="submit"> {loading ? "ストリーミング中…" : "送信"} </button> </form> {error ? <p className="error">{error}</p> : null} </section> </main> );}
app/layout.tsx
import type { Metadata } from "next";import "./globals.css";export const metadata: Metadata = { title: "Multi-Model Chat", description: "CometAPI で動くストリーミング対応の Next.js チャットアプリ。",};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode }>) { return ( <html lang="en"> <body>{children}</body> </html> );}
app/globals.css
:root { color-scheme: dark; font-family: Arial, sans-serif; background: #07111f; color: #eef4ff;}* { box-sizing: border-box; }body { margin: 0; }button, select, textarea { font: inherit; }/* レイアウトの外枠とチャットカード */.shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }.chat { width: min(820px, 100%); background: #0d1b2e; border: 1px solid #223957; border-radius: 20px; padding: 24px; }/* メッセージリストとバブル */.messages { min-height: 360px; display: grid; align-content: start; gap: 12px; margin: 24px 0; }.messages article { max-width: 85%; padding: 12px 14px; border-radius: 14px; white-space: pre-wrap; }.user { justify-self: end; background: #164f8f; }.assistant { justify-self: start; background: #182a42; }/* フォームコントロール、ボタン、エラー状態も同じダークテーマに従います。 */
Step 6: アプリを実行・デプロイする
npm run dev
http://localhost:3000 を開き、モデルを選択してメッセージを送信します。本番の Node.js デプロイでは、ホストのサーバー側環境設定に COMETAPI_API_KEY と COMETAPI_BASE_URL を追加し、次を実行します。
npm run buildnpm run start
ストリーミングレスポンスに対応したホスティング環境を使用してください。静的エクスポートでは /api/chat の Route Handler は実行できません。
ストリーミングルートをテストする
開発サーバーが動作している状態で、バックエンドを直接呼び出します。
curl -N http://localhost:3000/api/chat \ -H "Content-Type: application/json" \ -d '{ "model": "gemini-3.7-flash", "messages": [ {"role": "user", "content": "モデルのルーティングを2文で説明してください。"} ] }'
成功すると Server-Sent Events が返ります。ID とテキストは実際と異なる場合がありますが、ストリームは次の形で流れます。
data: {"choices":[{"delta":{"content":"モデル"}}]}data: {"choices":[{"delta":{"content":" ルーティング"}}]}data: [DONE]
ブラウザ側のパーサーは各 SSE イベントを読み取り、choices[0].delta.content を抽出し、到着したチャンクごとにアシスタントメッセージへ追記します。
よくある統合エラー
| 症状 | 原因 | 対処 |
|---|---|---|
| 401 認証エラー | サーバー側キーの欠如または不正 | COMETAPI_API_KEY を設定。NEXT_PUBLIC_ を付けて公開しない。 |
| 404 または誤ったルート | ベース URL に /v1 がない | https://api.cometapi.com/v1 を使用。 |
| 400 サポート外のモデル | ID がバックエンドの許可リストにない | 正確なライブのテキストモデル ID を使用し、両方のセレクターを更新。 |
| 回答が一度にまとまって表示される | ホストやプロキシがイベントストリームをバッファ | レスポンス変換を無効化し、ストリーミング対応の Node.js デプロイを使用。 |
本番環境に向けたチャットバックエンドの準備
- ユーザー自身を認証する。匿名トラフィックにクレジットを消費させる公開ルートは避ける。
- ユーザーと IP ごとにレート制限する。同時ストリーム数、毎分リクエスト数、メッセージ数、メッセージ長に上限を設ける。
- モデルの許可リストはサーバー側に保持する。ブラウザのセレクターは便宜であり、セキュリティ境界ではない。
- デプロイ時にライブカタログを検証する。
GEThttps://api.cometapi.com/api/modelsを問い合わせ、設定済み ID が upcoming、利用不可、または chat-completions エンドポイント未対応であればリリースを失敗させる。 - ルート別にコストを追跡する。選択モデル、リクエスト ID、レイテンシ、トークン使用量、ユーザー ID をログに記録する。必要に応じて CometAPI ダッシュボードでキーのクオータや上限を設定する。
- 切断とタイムアウトを扱う。
request.signalを維持し、アプリケーションのタイムアウトを設定し、クライアント離脱時に作業を停止する。 - フォールバックで設定エラーを隠さない。400 と 401 を表面化する。別モデルの使用は、再試行可能な限定的な失敗にのみ、かつリクエストスキーマが互換の場合に限る。2 段階のフォールバック連鎖(CometAPI プライマリ → CometAPI フォールバックモデル → 公式プロバイダー)については CometAPI Model Fallback Guide を参照。
- ログをマスキングする。API キー、完全なプロンプト、機密なモデル出力を本番のエラーログに残さない。
1 つの Next.js バックエンドで、複数のモデル選択を
モデル切替は別々のプロバイダーアカウントではなく、サーバーポリシー側に置くべきです。Next.js の Route Handler は 1 つの CometAPI キーを秘匿しつつ、各リクエストで承認済みのモデル ID を受け取り、OpenAI 互換のエンドポイント経由で選択モデルをストリームできます。フロントエンドはシンプルなまま、バックエンドはアクセス、検証、可観測性、コストを制御できます。同じアカウントとキーで CometAPI のネイティブな画像・動画 API(画像生成の Flux、動画生成の Kling など)にもアクセスできるため、このチャットバックエンドは第二の統合なしにマルチモーダルワークフローへ拡張可能です。
モデル探索には CometAPI の公開モデルディレクトリ を、ルーティングの自動検証には GET https://api.cometapi.com/api/models を使用してください。
よくある質問
1 つの Next.js アプリで複数の AI モデルを使えますか?
はい。サーバー側の単一ルートを維持し、各リクエストに許可リストに登録したモデル ID を渡します。ブラウザの UI でモデル選択を提供しつつ、サーバーが受け付ける ID を制御します。
GPT、Claude、Gemini をどう切り替えますか?
選択したモデル ID をリクエストボディで送信します。サーバーが許可リストに照らして検証し、OpenAI 互換エンドポイント経由で同じチャットペイロードを選択モデルに転送します。
CometAPI の API キーはどこに保存すべきですか?
COMETAPI_API_KEY のようなサーバー側の環境変数に保存します。NEXT_PUBLIC_ 付きの変数やクライアント側コードで公開してはいけません。
OpenAI 互換 API なら、すべてのモデルは相互に置換可能ですか?
いいえ。リクエスト形式は移植しやすいものの、サポートパラメータ、コンテキスト上限、ツールの挙動、出力スタイル、レイテンシ、価格は異なります。許可リストに入れる各モデルを本番プロンプトでテストしてください。
Next.js の Route Handler は AI 応答をストリームできますか?
はい。デプロイ基盤とその前段のプロキシがレスポンスをバッファ・変換しない限り、text/event-stream のコンテンツタイプで上流の Server-Sent Events を返せます。
このアプリを静的な Next.js サイトとしてデプロイできますか?
いいえ。静的エクスポートではサーバー側の /api/chat Route Handler を実行できず、API キーも保護できません。ストリーミングレスポンスに対応した Node.js などのサーバーランタイムを使用してください。
