取り組みの背景:Next.js × Gemini API が最強の組み合わせである理由
Next.js 15 は 2025 年末に安定版がリリースされ、App Router によるサーバーコンポーネント・Server Actions・Partial Prerendering(PPR)が実用段階に達しました。これと Gemini 2.5 Pro の組み合わせは、AI 機能を持つウェブアプリ開発において現在最も生産性の高い選択肢の一つです。
ここで扱うのは単なる "hello world" を超えた 本番運用に耐えうるフルスタック AI アプリ を構築するために必要な知識を体系的に解説します。対象読者は Next.js の基礎を理解しており、Gemini API を本格的なプロダクトに組み込みたい中〜上級者です。
この記事で扱うトピックは以下の通りです。
- Next.js 15 App Router のアーキテクチャと Gemini API の統合方針
- Server Components・Server Actions からの Gemini 呼び出しパターン
- Streaming レスポンスの完全実装(Edge Runtime 対応)
- RAG(Retrieval-Augmented Generation)パイプラインの構築
- 認証・レート制限・エラーハンドリングの本番実装
- Vercel / Cloudflare Workers へのデプロイと環境変数管理
- コスト最適化とキャッシング戦略
環境準備とプロジェクトセットアップ
Next.js 15 プロジェクトの初期化
npx create-next-app@latest my-gemini-app \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*"
cd my-gemini-app
# Gemini API SDK をインストール
npm install @google/generative-ai
# Vercel AI SDK(Streaming 対応)
npm install ai @ai-sdk/google
# 認証(オプション)
npm install next-auth@beta
環境変数の設定
.env.local を作成し、APIキーを設定します。
# .env.local
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
# Vercel AI SDK 用(@ai-sdk/google で自動的に参照される)
GOOGLE_GENERATIVE_AI_API_KEY=YOUR_GEMINI_API_KEY
重要: GEMINI_API_KEY という変数名には NEXT_PUBLIC_ プレフィックスを絶対に付けないでください。クライアントサイドにAPIキーが露出します。すべての Gemini API 呼び出しはサーバーサイド(Server Component / Server Actions / Route Handler)から行います。
Gemini API クライアントの初期化(シングルトンパターン)
// src/lib/gemini.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
// Next.js の開発環境でのホットリロード対策(シングルトン)
const globalForGemini = globalThis as unknown as {
geminiClient: GoogleGenerativeAI | undefined;
};
export const geminiClient =
globalForGemini.geminiClient ??
new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
if (process.env.NODE_ENV !== "production") {
globalForGemini.geminiClient = geminiClient;
}
// よく使うモデルを取得するヘルパー
export function getModel(modelName = "gemini-2.5-pro") {
return geminiClient.getGenerativeModel({ model: modelName });
}
Server Components から Gemini API を呼び出す
App Router の Server Components は、データフェッチと AI 生成を同じコンポーネント内で完結させられる点が優れています。ページのビルド時または閲覧時に Gemini API を呼び出し、その結果を静的または動的に描画できます。
// src/app/summary/[slug]/page.tsx
import { getModel } from "@/lib/gemini";
import { getArticleContent } from "@/lib/articles";
interface Props {
params: { slug: string };
}
// この関数は完全にサーバーサイドで実行される
async function generateSummary(content: string): Promise<string> {
const model = getModel("gemini-2.5-flash"); // コスト節約に Flash を使用
const result = await model.generateContent({
contents: [
{
role: "user",
parts: [
{
text: `以下の記事を日本語で3〜5文に要約してください。重要なポイントを簡潔にまとめてください。\n\n${content}`,
},
],
},
],
generationConfig: {
maxOutputTokens: 512,
temperature: 0.3,
},
});
return result.response.text();
}
export default async function SummaryPage({ params }: Props) {
const article = await getArticleContent(params.slug);
// サーバーサイドで AI 要約を生成
const summary = await generateSummary(article.content);
return (
<article>
<h1>{article.title}</h1>
{/* AI 生成の要約 */}
<div className="bg-blue-50 p-4 rounded-lg">
<h2 className="font-bold text-blue-800 mb-2">AI 要約</h2>
<p className="text-blue-700">{summary}</p>
</div>
{/* 本文 */}
<div dangerouslySetInnerHTML={{ __html: article.html }} />
</article>
);
}
// ISR: 1時間ごとに再生成(コスト最適化)
export const revalidate = 3600;
Streaming レスポンスの完全実装
AI チャットアプリで最も重要なのが、ユーザー体験を向上させる Streaming です。Gemini API は Server-Sent Events(SSE)ベースのストリーミングをサポートしており、Next.js の Edge Runtime と組み合わせることで低レイテンシな実装が可能です。
Vercel AI SDK を使った Streaming Route Handler
// src/app/api/chat/route.ts
import { google } from "@ai-sdk/google";
import { streamText, convertToCoreMessages } from "ai";
import { NextRequest } from "next/server";
// Edge Runtime で実行(低レイテンシ)
export const runtime = "edge";
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const result = await streamText({
model: google("gemini-2.5-pro"),
system: `あなたは親切で丁寧なAIアシスタントです。
ユーザーの質問に対して、正確かつわかりやすい日本語で回答してください。
コード例を示す場合は、適切な説明とともに提示してください。`,
messages: convertToCoreMessages(messages),
maxTokens: 2048,
temperature: 0.7,
// ストリーミング中のコールバック(ログ・分析用)
onFinish: async ({ usage, finishReason }) => {
console.log("Tokens used:", usage.totalTokens);
console.log("Finish reason:", finishReason);
// 本番ではここでデータベースに使用量を記録
},
});
return result.toDataStreamResponse();
}
クライアントサイドのチャット UI
// src/app/chat/page.tsx
"use client";
import { useChat } from "ai/react";
import { useState } from "react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: "/api/chat",
// エラー時の処理
onError: (err) => {
console.error("Chat error:", err);
},
});
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Gemini AI チャット</h1>
{/* メッセージ表示エリア */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg p-3 ${
message.role === "user"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
{/* ストリーミング中はリアルタイムでテキストが追加される */}
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))}
{/* ローディングインジケーター */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
{/* エラー表示 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-3 rounded-lg mb-4">
エラーが発生しました: {error.message}
</div>
)}
{/* 入力フォーム */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="メッセージを入力..."
disabled={isLoading}
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
送信
</button>
</form>
</div>
);
}
Server Actions × Gemini API
Server Actions は Next.js 15 での中心的な機能であり、フォーム送信と AI 処理を統合するのに最適です。クライアントから直接関数呼び出しのように AI 処理を実行できます。
// src/app/actions/ai.ts
"use server";
import { getModel } from "@/lib/gemini";
import { revalidatePath } from "next/cache";
import { z } from "zod";
// 入力バリデーションスキーマ
const generateContentSchema = z.object({
topic: z.string().min(1).max(500),
style: z.enum(["blog", "tweet", "email", "summary"]),
language: z.enum(["ja", "en"]).default("ja"),
});
type GenerateContentInput = z.infer<typeof generateContentSchema>;
// Server Action: コンテンツ生成
export async function generateContent(input: GenerateContentInput) {
// バリデーション
const validated = generateContentSchema.safeParse(input);
if (!validated.success) {
return { error: "入力値が不正です", details: validated.error.flatten() };
}
const { topic, style, language } = validated.data;
const prompts: Record<string, string> = {
blog: `「${topic}」についての情報価値の高いブログ記事を書いてください。見出しと本文を含む2000文字程度でお願いします。`,
tweet: `「${topic}」について、Twitterに投稿するための魅力的な短文(140文字以内)を3つ考えてください。`,
email: `「${topic}」についてのビジネスメールの文面を作成してください。件名と本文を含めてください。`,
summary: `「${topic}」について簡潔に要約してください(300文字以内)。`,
};
try {
const model = getModel("gemini-2.5-pro");
const result = await model.generateContent(prompts[style]);
const content = result.response.text();
// 必要に応じてキャッシュを更新
revalidatePath("/dashboard");
return { success: true, content };
} catch (error) {
console.error("Gemini API error:", error);
return { error: "コンテンツ生成中にエラーが発生しました" };
}
}
// src/app/generate/page.tsx
"use client";
import { generateContent } from "@/app/actions/ai";
import { useState, useTransition } from "react";
export default function GeneratePage() {
const [isPending, startTransition] = useTransition();
const [result, setResult] = useState<string>("");
const [error, setError] = useState<string>("");
async function handleSubmit(formData: FormData) {
startTransition(async () => {
const response = await generateContent({
topic: formData.get("topic") as string,
style: formData.get("style") as "blog" | "tweet" | "email" | "summary",
language: "ja",
});
if (response.error) {
setError(response.error);
} else {
setResult(response.content ?? "");
setError("");
}
});
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">AI コンテンツジェネレーター</h1>
<form action={handleSubmit} className="space-y-4">
<div>
<label className="block font-medium mb-1">トピック</label>
<input
name="topic"
required
placeholder="例: Next.js 15 の新機能"
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div>
<label className="block font-medium mb-1">コンテンツスタイル</label>
<select name="style" className="w-full border rounded-lg px-4 py-2">
<option value="blog">ブログ記事</option>
<option value="tweet">Twitter投稿</option>
<option value="email">ビジネスメール</option>
<option value="summary">要約</option>
</select>
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? "生成中..." : "コンテンツを生成"}
</button>
</form>
{error && (
<div className="mt-4 bg-red-50 text-red-700 p-4 rounded-lg">{error}</div>
)}
{result && (
<div className="mt-6">
<h2 className="font-bold mb-2">生成結果:</h2>
<div className="bg-gray-50 p-4 rounded-lg whitespace-pre-wrap">{result}</div>
</div>
)}
</div>
);
}
RAG パイプラインの構築
RAG(Retrieval-Augmented Generation)は、自社データや最新情報を Gemini に組み合わせるための重要な手法です。ここでは Gemini Embeddings API を使った実用的な RAG を実装します。
Embeddings を使ったドキュメント検索
// src/lib/rag.ts
import { geminiClient } from "@/lib/gemini";
const embeddingModel = geminiClient.getGenerativeModel({
model: "text-embedding-004",
});
// ドキュメントのベクトル化
export async function embedText(text: string): Promise<number[]> {
const result = await embeddingModel.embedContent({
content: { parts: [{ text }], role: "user" },
taskType: "RETRIEVAL_DOCUMENT",
});
return result.embedding.values;
}
// クエリのベクトル化
export async function embedQuery(query: string): Promise<number[]> {
const result = await embeddingModel.embedContent({
content: { parts: [{ text: query }], role: "user" },
taskType: "RETRIEVAL_QUERY",
});
return result.embedding.values;
}
// コサイン類似度の計算
export function cosineSimilarity(a: number[], b: number[]): number {
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
return dotProduct / (magnitudeA * magnitudeB);
}
// ドキュメントの型定義
interface Document {
id: string;
content: string;
embedding?: number[];
metadata?: Record<string, unknown>;
}
// インメモリのシンプルなベクトルストア(本番では pgvector や Pinecone を使用)
class SimpleVectorStore {
private documents: (Document & { embedding: number[] })[] = [];
async addDocument(doc: Document): Promise<void> {
const embedding = await embedText(doc.content);
this.documents.push({ ...doc, embedding });
}
async search(query: string, topK = 3): Promise<Document[]> {
const queryEmbedding = await embedQuery(query);
const scored = this.documents.map((doc) => ({
doc,
score: cosineSimilarity(queryEmbedding, doc.embedding),
}));
return scored
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(({ doc }) => doc);
}
}
// グローバルストア(本番ではデータベースに永続化する)
const vectorStore = new SimpleVectorStore();
// RAG を使った回答生成
export async function ragAnswer(
query: string,
systemPrompt = "あなたは親切なAIアシスタントです。提供されたコンテキストに基づいて回答してください。"
): Promise<string> {
// 1. 関連ドキュメントを検索
const relevantDocs = await vectorStore.search(query, 3);
if (relevantDocs.length === 0) {
return "関連情報が見つかりませんでした。";
}
// 2. コンテキストを構築
const context = relevantDocs
.map((doc, i) => `[ドキュメント${i + 1}]\n${doc.content}`)
.join("\n\n");
// 3. Gemini で回答生成
const model = geminiClient.getGenerativeModel({ model: "gemini-2.5-pro" });
const prompt = `${systemPrompt}
以下のコンテキストを参考にして、ユーザーの質問に回答してください。
コンテキストにない情報については、その旨を明示してください。
コンテキスト:
${context}
ユーザーの質問: ${query}`;
const result = await model.generateContent(prompt);
return result.response.text();
}
export { vectorStore };
認証・レート制限・エラーハンドリング
本番環境では、Gemini API の不正使用を防ぎ、コストを管理するための認証とレート制限が必須です。
API Route のミドルウェアによる保護
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// レート制限のシンプルな実装(本番では Redis/Upstash を使用)
const requestCounts = new Map<string, { count: number; resetAt: number }>();
function getRateLimit(ip: string): boolean {
const now = Date.now();
const windowMs = 60 * 1000; // 1分
const maxRequests = 10; // 1分あたり最大10リクエスト
const current = requestCounts.get(ip);
if (!current || current.resetAt < now) {
requestCounts.set(ip, { count: 1, resetAt: now + windowMs });
return true;
}
if (current.count >= maxRequests) {
return false;
}
current.count++;
return true;
}
export function middleware(request: NextRequest) {
// API ルートのみ適用
if (request.nextUrl.pathname.startsWith("/api/")) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
if (!getRateLimit(ip)) {
return NextResponse.json(
{ error: "リクエスト制限を超えました。1分後に再試行してください。" },
{ status: 429, headers: { "Retry-After": "60" } }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
};
Gemini API エラーの統一ハンドリング
// src/lib/gemini-error-handler.ts
import { NextResponse } from "next/server";
type GeminiError = {
status?: number;
message?: string;
code?: string;
};
export function handleGeminiError(error: unknown): NextResponse {
const err = error as GeminiError;
console.error("Gemini API Error:", err);
// レート制限エラー(429)
if (err.status === 429 || err.code === "RESOURCE_EXHAUSTED") {
return NextResponse.json(
{
error: "APIの使用制限に達しました。しばらく後にお試しください。",
retryAfter: 60,
},
{ status: 429 }
);
}
// 認証エラー(401)
if (err.status === 401 || err.code === "UNAUTHENTICATED") {
return NextResponse.json(
{ error: "API認証に失敗しました。設定を確認してください。" },
{ status: 500 } // クライアントにはサーバーエラーとして返す
);
}
// コンテンツポリシー違反(400)
if (err.status === 400 || err.code === "INVALID_ARGUMENT") {
return NextResponse.json(
{ error: "入力内容を確認してください。" },
{ status: 400 }
);
}
// サービス停止(503)
if (err.status === 503 || err.code === "UNAVAILABLE") {
return NextResponse.json(
{ error: "サービスが一時的に利用できません。" },
{ status: 503 }
);
}
// その他のエラー
return NextResponse.json(
{ error: "予期せぬエラーが発生しました。" },
{ status: 500 }
);
}
本番デプロイ:Vercel と Cloudflare Workers
Vercel へのデプロイ(推奨)
Vercel は Next.js のファーストクラスホスティングであり、Edge Runtime・Streaming・サーバーレス関数のすべてをシームレスにサポートします。
# Vercel CLI でデプロイ
npm i -g vercel
vercel
# 環境変数を設定
vercel env add GEMINI_API_KEY production
vercel env add GOOGLE_GENERATIVE_AI_API_KEY production
vercel.json でリージョンと関数のタイムアウトを設定します。
{
"functions": {
"src/app/api/chat/route.ts": {
"maxDuration": 60
},
"src/app/api/generate/route.ts": {
"maxDuration": 30
}
},
"regions": ["hnd1"]
}
Cloudflare Workers(OpenNext)へのデプロイ
コスト最適化や日本リージョンへのデプロイには Cloudflare Workers + OpenNext が有効です。
npm install @opennextjs/cloudflare wrangler
# wrangler.toml を設定
cat > wrangler.toml << 'EOF'
name = "my-gemini-app"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
main = ".open-next/worker.js"
[vars]
NEXT_PUBLIC_BASE_URL = "https://my-gemini-app.example.com"
[build]
command = "npm run build"
EOF
# デプロイ
npx wrangler deploy
コスト最適化とキャッシング戦略
Gemini 2.5 Pro は高品質ですが、コストも高めです。適切なキャッシュ戦略と、用途に応じたモデル選択でコストを大幅に削減できます。
モデル使い分けのガイドライン
- Gemini 2.5 Pro: 複雑な推論・長文生成・コード生成(重要な処理)
- Gemini 2.5 Flash: 要約・分類・シンプルな質問応答(頻度の高い処理)
- Gemini 2.0 Flash Lite: 大量バッチ処理・ログ分析(コスト最優先)
- text-embedding-004: Embeddings(RAG のベクトル生成)
Next.js キャッシュを使ったコスト削減
// src/lib/cached-gemini.ts
import { unstable_cache } from "next/cache";
import { getModel } from "@/lib/gemini";
// 同一入力の結果をキャッシュ(1時間)
export const cachedGenerateSummary = unstable_cache(
async (content: string): Promise<string> => {
const model = getModel("gemini-2.5-flash");
const result = await model.generateContent(
`以下の文章を3文以内で要約してください:\n\n${content}`
);
return result.response.text();
},
["gemini-summary"],
{
revalidate: 3600, // 1時間キャッシュ
tags: ["gemini-cache"],
}
);
// 使用例
const summary = await cachedGenerateSummary(articleContent);
Context Caching(大規模ドキュメント処理)
同一のシステムプロンプトや長いドキュメントを繰り返し使う場合、Context Caching を活用すると大幅なコスト削減が可能です。入力トークンのキャッシュ料金は通常の約25%に抑えられます。
パフォーマンス最適化:Partial Prerendering(PPR)
Next.js 15 の PPR を使うと、静的シェルと動的な AI コンテンツを最適に組み合わせられます。
// src/app/dashboard/page.tsx
import { Suspense } from "react";
import { AiInsights } from "@/components/AiInsights";
import { StaticHeader } from "@/components/StaticHeader";
// PPR を有効化(next.config.ts で設定)
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<main>
{/* 静的シェル:即座に描画 */}
<StaticHeader />
{/* 動的 AI コンテンツ:Streaming で段階的に描画 */}
<Suspense fallback={<div className="animate-pulse h-32 bg-gray-100 rounded" />}>
<AiInsights />
</Suspense>
</main>
);
}
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true, // Partial Prerendering を有効化
},
};
export default nextConfig;
個人開発者の視点から(実体験メモ)
まとめ
ここで扱うのはNext.js 15 App Router と Gemini API を組み合わせたフルスタック AI アプリの本番構築について、以下の点を包括的に解説しました。
- シングルトンパターンによる Gemini クライアントの適切な初期化
- Server Components・Server Actions からの安全な API 呼び出し
- Vercel AI SDK を使った Streaming チャットの完全実装
- Gemini Embeddings API を活用した RAG パイプラインの構築
- ミドルウェアによるレート制限と統一エラーハンドリング
- Vercel / Cloudflare Workers へのデプロイ戦略
unstable_cache と Context Caching を使ったコスト最適化
Next.js × Gemini の組み合わせは、AIファーストなウェブアプリを最短距離で構築できる強力な選択肢です。ぜひ本記事の実装パターンを参考に、あなた自身のプロダクトに Gemini の力を組み込んでみてください。
Gemini API の Streaming 実装をさらに深く理解したい方には、Gemini API React AI チャット UI の構築ガイドもあわせてご覧ください。
App Routerの設計思想から本番運用まで体系的に学べる一冊です。