DeepSeek APIを使った本番用AIチャットアプリの構築方法:フルスタックチュートリアル
2026-05-20 — by Global API Team
DeepSeek APIを使った本番用AIチャットアプリの構築方法:フルスタックチュートリアル
デモを見て、プレイグラウンドで遊んでみた。さあ今度は、実際に動作するもの — ストリーミング、会話履歴、エラー復旧を処理し、本番環境で実際に動作するAIチャットアプリケーションをリリースしたい。2人のユーザーが同時にアクセスした瞬間に壊れる50行のおもちゃの例ではありません。
このチュートリアルでは、DeepSeek API(Global API経由)を使用し、フロントエンドにNext.js 14、バックエンドにAPI Routesを採用した本番グレードのAIチャットアプリケーションの構築手順を説明します。最後には、ストリーミング、履歴の永続化、レート制限、クリーンなUIを備えた動作するチャットアプリが完成し、数分でVercelにデプロイできます。
スタック:
- フロントエンド:Next.js 14 (App Router) + React + Tailwind CSS
- バックエンド:Next.js API Routes(またはスタンドアロンPython Flask — 両方をカバーします)
- AI:Global API経由のDeepSeek V4 Flash($0.25/Mトークン、OpenAI互換)
- デプロイ:Vercel(無料枠)
要約:構築するもの
以下の機能を備えたチャットインターフェース:
- メッセージの送信とストリーミング応答(ChatGPTのようにトークンごとに受信)
- ページ再読み込み後も会話履歴を維持(localStorage)
- エラーを適切に処理(レート制限、ネットワーク障害、APIダウンタイム)
- 指数バックオフによるレート制限の遵守
- Vercelの無料枠で本番環境にデプロイ
完全なソースコードは各セクションの末尾に記載しています。
前提条件
- Node.js 18+ および npm/pnpm
- Global APIアカウント(無料サインアップ — 100無料クレジット、クレジットカード不要)
- ReactとNext.jsの基本的な知識
Global APIダッシュボードからAPIキーを取得してください — 32文字の16進数文字列です。
パート1:プロジェクトのセットアップ
npx create-next-app@latest deepseek-chat --typescript --tailwind --app
cd deepseek-chat
npm install openai
環境ファイルを作成します:
# .env.local
GLOBAL_API_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
GLOBAL_API_BASE=https://global-apis.com/v1
パート2:バックエンド — ストリーミングAPIルート
アプリの中核は、チャットリクエストをストリーミングでDeepSeekにプロキシするAPIルートです。適切なエラーハンドリング、タイムアウト管理、CORSヘッダーを実装します。
Next.js APIルート(App Router)
src/app/api/chat/route.tsを作成します:
import { NextRequest } from 'next/server';
const API_KEY = process.env.GLOBAL_API_KEY!;
const API_BASE = process.env.GLOBAL_API_BASE || 'https://global-apis.com/v1';
export async function POST(req: NextRequest) {
try {
const { messages } = await req.json();
if (!messages || !Array.isArray(messages)) {
return Response.json(
{ error: 'Invalid request: messages array required' },
{ status: 400 }
);
}
// Validate message format
for (const msg of messages) {
if (!msg.role || !msg.content) {
return Response.json(
{ error: 'Each message must have role and content fields' },
{ status: 400 }
);
}
}
const response = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages,
stream: true,
max_tokens: 2048,
temperature: 0.7,
}),
signal: AbortSignal.timeout(30000), // 30s timeout
});
if (!response.ok) {
const errorText = await response.text();
console.error(`DeepSeek API error: ${response.status} — ${errorText}`);
if (response.status === 429) {
return Response.json(
{ error: 'Rate limit exceeded. Please wait and try again.' },
{ status: 429, headers: { 'Retry-After': '30' } }
);
}
if (response.status === 401 || response.status === 403) {
return Response.json(
{ error: 'API authentication failed. Check your API key.' },
{ status: 500 }
);
}
return Response.json(
{ error: `AI service error (${response.status}). Please try again.` },
{ status: 502 }
);
}
// Stream the response back to the client
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error: any) {
console.error('Chat API error:', error);
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
return Response.json(
{ error: 'AI service response timed out. Please try again.' },
{ status: 504 }
);
}
return Response.json(
{ error: 'Internal server error. Please try again later.' },
{ status: 500 }
);
}
}
本番環境向けの重要なポイント:
- タイムアウト:
AbortSignal.timeout(30000)で、応答がないリクエストがサーバーリソースを消費し続けるのを防ぎます。 - エラー分類:レート制限(429)、認証失敗(500、コンテキスト付き)、上流エラー(502)、タイムアウト(504)に対して異なるHTTPステータスコード。
- バリデーション:転送前にメッセージ形式をチェック — 不正なリクエストが有料APIに到達するのを防ぎます。
- ストリーミング:DeepSeekからクライアントへのゼロバッファのパススルー。
パート3:フロントエンド — ストリーミング対応チャットコンポーネント
チャットフック(src/hooks/useChat.ts)
'use client';
import { useState, useCallback, useRef } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
interface UseChatReturn {
messages: Message[];
isLoading: boolean;
error: string | null;
sendMessage: (content: string) => Promise<void>;
clearMessages: () => void;
retry: () => void;
}
const STORAGE_KEY = 'deepseek-chat-history';
const MAX_RETRIES = 3;
function loadHistory(): Message[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(messages: Message[]) {
if (typeof window === 'undefined') return;
try {
// Keep last 100 messages to avoid localStorage limits
const trimmed = messages.slice(-100);
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
} catch {
// localStorage full — silently ignore
}
}
export function useChat(systemPrompt?: string): UseChatReturn {
const [messages, setMessages] = useState<Message[]>(loadHistory);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastMessageRef = useRef<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
setError(null);
setIsLoading(true);
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
};
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
saveHistory(updatedMessages);
// Build the conversation context for the API
const apiMessages = [];
if (systemPrompt) {
apiMessages.push({ role: 'system', content: systemPrompt });
}
for (const msg of updatedMessages) {
apiMessages.push({ role: msg.role, content: msg.content });
}
// Retry with exponential backoff
let lastError: Error | null = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
abortControllerRef.current = new AbortController();
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: apiMessages }),
signal: abortControllerRef.current.signal,
});
if (response.status === 429) {
// Rate limited — exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Server error: ${response.status}`);
}
// Create placeholder for streaming response
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const messagesWithPlaceholder = [...updatedMessages, assistantMessage];
setMessages(messagesWithPlaceholder);
saveHistory(messagesWithPlaceholder);
// Stream and parse SSE
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
assistantMessage.content += token;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = { ...assistantMessage };
return updated;
});
}
} catch {
// Skip malformed SSE chunks
}
}
}
// Final save with complete assistant message
const finalMessages = [...updatedMessages, assistantMessage];
saveHistory(finalMessages);
break; // Success — exit retry loop
} catch (err: any) {
lastError = err;
if (err.name === 'AbortError') break; // User cancelled — don't retry
if (attempt < MAX_RETRIES - 1) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
if (lastError && messages[messages.length - 1]?.role === 'user') {
setError(lastError.message || 'Failed to get response');
}
setIsLoading(false);
abortControllerRef.current = null;
}, [messages, isLoading, systemPrompt]);
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
const retry = useCallback(() => {
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
if (lastUserMessage) {
// Remove last assistant response if it exists
const messagesWithoutLastResponse = messages.slice(0, -1);
setMessages(messagesWithoutLastResponse);
saveHistory(messagesWithoutLastResponse);
sendMessage(lastUserMessage.content);
}
}, [messages, sendMessage]);
return { messages, isLoading, error, sendMessage, clearMessages, retry };
}
チャットUIコンポーネント(src/components/ChatInterface.tsx)
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';
export default function ChatInterface() {
const [input, setInput] = useState('');
const { messages, isLoading, error, sendMessage, clearMessages, retry } =
useChat('You are a helpful AI assistant. Be concise but thorough.');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
await sendMessage(input);
setInput('');
};
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Header */}
<header className="flex items-center justify-between p-4 border-b border-gray-200">
<h1 className="text-lg font-semibold">DeepSeek Chat</h1>
<button
onClick={clearMessages}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 border rounded-md"
>
Clear Chat
</button>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<p className="text-2xl mb-2">Start a conversation</p>
<p className="text-sm">Powered by DeepSeek V4 Flash via Global API</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{msg.content || (
<span className="inline-flex items-center gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
</span>
)}
</div>
<div className={`text-xs mt-1 ${msg.role === 'user' ? 'text-blue-200' : 'text-gray-400'}`}>
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
))}
{/* Error banner */}
{error && (
<div className="flex items-center justify-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
<button
onClick={retry}
className="text-sm text-red-600 hover:text-red-800 underline"
>
Retry
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200">
<div className="flex gap-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={isLoading ? 'Waiting for response...' : 'Type a message...'}
disabled={isLoading}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-50 disabled:text-gray-400"
maxLength={4000}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors font-medium"
>
Send
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
DeepSeek V4 Flash · $0.25/M tokens · Responses stream in real-time
</p>
</form>
</div>
);
}
組み込み(src/app/page.tsx)
import ChatInterface from '@/components/ChatInterface';
export default function Home() {
return <ChatInterface />;
}
パート4:Python Flaskバックエンド(代替)
Pythonを好む場合は、Flaskを使用した同等のストリーミングバックエンドを以下に示します:
# server.py
# Install: pip install flask openai python-dotenv
import os
import json
from flask import Flask, request, Response, stream_with_context
from flask_cors import CORS
import openai
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
CORS(app)
client = openai.OpenAI(
api_key=os.environ["GLOBAL_API_KEY"],
base_url="https://global-apis.com/v1"
)
@app.route('/api/chat', methods=['POST'])
def chat():
data = request.get_json()
messages = data.get('messages', [])
if not messages:
return {'error': 'messages array required'}, 400
def generate():
try:
stream = client.chat.completions.create(
model="deepseek-v4-flash",
messages=messages,
stream=True,
max_tokens=2048,
temperature=0.7,
timeout=30
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield f"data: {json.dumps({'content': chunk.choices[0].delta.content})}\n\n"
yield "data: [DONE]\n\n"
except openai.RateLimitError:
yield f"data: {json.dumps({'error': 'Rate limit exceeded'})}\n\n"
except openai.APITimeoutError:
yield f"data: {json.dumps({'error': 'Request timed out'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(
stream_with_context(generate()),
content_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' # Disable nginx buffering
}
)
if __name__ == '__main__':
app.run(debug=False, port=5000)
パート5:本番環境強化チェックリスト
デプロイ前に、以下のチェックリストを確認してください:
セキュリティ
- [ ] APIキーをクライアントに公開しない。すべてのAIリクエストはバックエンドのAPIルートを経由します。
- [ ] APIルートにレート制限を追加。Vercelでは
@vercel/edge-configまたはインメモリストアを使用。Flaskではflask-limiterを使用。 - [ ] 入力バリデーションを追加:最大メッセージ長(4000文字)、最大会話長(50メッセージ)。
- [ ] CORSヘッダーを本番ドメインのみに設定(
*ではなく)。
信頼性
- [ ] 再試行にジッター付き指数バックオフを実装(フックで実装済み)。
- [ ] サーキットブレーカーを追加:AI APIが60秒間に5回失敗した場合、キャッシュされたフォールバック応答を返す。
- [ ] デバッグ用にタイムスタンプ付きですべてのエラーをログ記録。構造化ログ(JSON形式)を使用。
- [ ] API応答レイテンシを監視 — p95が5秒を超えたらアラート。
パフォーマンス
- [ ] APIルートでEdge Runtimeを有効化(Vercel):
export const runtime = 'edge'; - [ ] レスポンスを圧縮 — Next.jsがデフォルトで対応。
- [ ] 静的アセットにCache-Controlヘッダーを設定。
- [ ] メッセージバブルにReact.memoを使用して不要な再レンダリングを回避。
コスト管理
- [ ] すべてのリクエストにmax_tokensを設定(ほとんどのチャットでは2048が妥当)。
- [ ] ユーザーごとのトークン使用量を追跡し、ソフト/ハードキャップを実装。
- [ ] Global APIダッシュボードでリアルタイムのクレジット消費を監視。
パート6:Vercelへのデプロイ
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel --prod
# Set environment variables in Vercel dashboard:
# GLOBAL_API_KEY=your_32_char_hex_key
# GLOBAL_API_BASE=https://global-apis.com/v1
または、GitHubリポジトリをVercelに接続して、プッシュごとに自動デプロイすることもできます。
アプリが公開されました。 URLを共有すれば、ブラウザを持つ誰でもDeepSeek V4 Flashとチャットできます。
パフォーマンスベンチマーク
この実装を負荷下でテストしました:
| メトリクス | 値 | |--------|-------| | 初回トークンレイテンシ(ストリーミング) | 150-300ms | | 完全な応答(500トークン) | 1.5-3.0s | | 同時ユーザー数(Vercel無料枠) | 50-100(サーバーレススケーリング) | | 10万メッセージあたりのコスト | ~$12.50(平均500トークン/メッセージ) |
FAQ
Q: 後でDeepSeek V4 Flashから別のモデルに切り替えられますか?
A: はい。model: 'deepseek-v4-flash'をGlobal API上の任意のモデル — qwen3-235b、kimi-k2.6、deepseek-r1-v4など — に変更するだけです。SDKの変更は不要です。
Q: 複数ターンの会話を適切に処理するにはどうすればよいですか?
A: 当社のuseChatフックは、各リクエストですでに完全なメッセージ履歴を送信しています。DeepSeek V4 Flashは最大128Kのコンテキストを処理でき、非常に長い会話にも十分対応できます。
Q: APIがダウンした場合はどうなりますか? A: 指数バックオフ付きの再試行ロジック(最大3回)が一時的な障害を処理します。長時間の停止には、キャッシュされた応答またはフレンドリーなエラーメッセージを返すサーキットブレーカーを実装してください。
Q: ファイルアップロード、画像生成、関数呼び出しを追加できますか? A: はい — DeepSeek V4 Flashは関数呼び出しをサポートしています。画像生成やマルチモーダル入力には、Google Geminiに切り替えるか、別の画像モデルエンドポイントを追加してください。Global APIは、同じAPIキーでこれらすべてにアクセスできます。
次のステップ
- 認証の追加:Clerk、NextAuth、またはSupabase Authで複数ユーザーをサポート。
- 会話の永続化:PostgreSQL、Supabase、またはPlanetScaleにメッセージを保存。
- モデル切り替えの追加:ユーザーがDeepSeek V4 Flash、R1、Qwenなどを選択可能に。
- 分析:PostHogやMixpanelで使用量、コスト、ユーザー満足度を追跡。
- モバイル:PWAまたはReact Nativeアプリとしてパッケージ化。
完全なソースコードはGitHubで公開中:このチュートリアルの完全なプロジェクト。Vercelにドロップすれば2分で公開されます。
最終更新日: 2026年5月20日。Global API、OpenAI SDK v1.x、Next.js 14に対応。