【第1回】T3 TurboとGeminiでチャットボットを構築する方法
多くの創業者が「環境構築の沼」にハマりがちですが、私はたった半日で完全型安全なAIチャットボットを構築しました。Next.js、tRPC、Geminiを組み合わせた具体的なスタックと、あなたがすぐに実践できるコードをすべて公開します。

タイトル:T3 StackとGeminiで爆速開発:チャットボット構築完全ガイド 抜粋:複雑さはスタートアップの敵です。T3 Turbo、Drizzle、Google Geminiを使って、アイデアからデプロイまで数時間で完了する「Vibe Coding」な開発フローを紹介します。
複雑さは、初期段階のスタートアップにとって「静かなる殺人者」です。「トニー・スタークのように話すチャットボットを作りたい」というシンプルなアイデアから始まったはずが、3週間後にはまだWebpackの設定をいじっていたり、Dockerコンテナと格闘していたり、誰も使っていない認証フローのデバッグに追われていたりします。
これは、才能あふれる優秀なエンジニアたちが何度も陥る罠です。私たちはツールを愛しています。最適化が大好きです。しかし、スタートアップというゲームにおいて、「プロダクトを世に出すこと(Shipping)」こそが唯一の重要な指標なのです。
最近のTypeScriptエコシステムを見ていないなら、驚くかもしれません。バラバラのAPIをつぎはぎして、なんとか動くように祈る日々は、ほぼ過去のものとなりました。私たちは今、「Vibe Coder(バイブコーダー)」の時代に突入しています。アイデアからデプロイされたプロダクトまでの距離は、スプリント単位ではなく、時間単位で測られる時代です。
今日は、まるでチートコードのようなスタックを紹介します。Create T3 Turbo と Google Gemini AI の組み合わせです。データベースからフロントエンドまで型安全で、驚くほど高速。そして正直なところ、コーディングの楽しさを取り戻してくれます。
なぜこのスタックなのか?
「Feng Liu、なぜまた新しいスタックなんだ? PythonとStreamlitじゃダメなのか?」と思うかもしれません。
プロトタイプなら、それでもいいでしょう。しかし、もしあなたが「プロダクト」――つまり、スケールし、ユーザーを処理し、状態を維持する必要があるもの――を作ろうとしているなら、本物のアーキテクチャが必要です。問題は、「本物のアーキテクチャ」は大抵の場合、「数週間分のボイラープレート(定型コード)」を意味することです。
T3 Stack (Next.js, tRPC, Tailwind) はこのシナリオを覆します。スクリプトを書くような開発スピードで、フルスタックアプリケーションの堅牢さを提供してくれます。そこに Drizzle ORM(軽量でSQLライク)と Google Gemini(高速で無料枠が寛大)を加えれば、たった一人の創業者でも10人のチームを出し抜けるツールキットが手に入ります。
さあ、本物を作りましょう。
ステップ 1: コマンド一発でセットアップ
ESLintやPrettierの手動設定は忘れましょう。create-t3-turboを使います。これはモノレポ構成をセットアップしてくれるので、APIロジックとNext.jsフロントエンドを分離でき、将来的にReact Nativeアプリを出す際にも役立ちます。
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
質問されたら、Next.js、tRPC、PostgreSQLを選択してください。Auth(認証)は今のところスキップします。繰り返しますが、私たちは「完璧さ」ではなく「出荷」に最適化しているからです。NextAuthは後から10分で追加できます。
生成されるモノレポ構成:
my-chatbot/
├── apps/nextjs/ # Webアプリ
├── packages/
│ ├── api/ # tRPCルーター (共有ロジック)
│ ├── db/ # データベーススキーマ + Drizzle
│ └── ui/ # 共有コンポーネント
この分離のおかげで、APIロジックをWeb、モバイル、さらにはCLIアプリでも再利用できます。すべてを一つのフォルダに入れて始めたせいで、リファクタリングに数ヶ月を費やしたチームを私は見てきました。
ステップ 2: 頭脳 (Gemini)
OpenAIも素晴らしいですが、Gemini Flashは試しましたか? 驚くほど高速で、価格設定も攻撃的です。レイテンシ(遅延)が命取りになるチャットインターフェースにおいて、スピードは「機能」そのものです。
なぜGPT-3.5/4ではなくGemini Flashなのか?
- 速度: 応答時間 約800ms vs 2-3秒
- コスト: GPT-4より60倍安い
- コンテキスト: 100万トークンのコンテキストウィンドウ(そう、100万です)
LLMとの対話を標準化するために、AI SDKが必要です。
cd packages/api
pnpm add ai @ai-sdk/google
プロジェクトルートに.envを設定します。ローカル開発でデータベースについて考えすぎるのはやめましょう。ローカルのPostgresインスタンスで十分です。
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"
プロのヒント: GeminiのAPIキーは https://aistudio.google.com/app/apikey から取得してください。無料枠は異常なほど寛大で、1分間に60リクエストまで可能です。レート制限に引っかかる前に、プロダクトマーケットフィット(PMF)に到達するでしょう。
ステップ 3: 現実を定義する (スキーマ)
ここでDrizzleが輝きます。昔はマイグレーションを手書きしていましたが、今はTypeScriptでスキーマを定義すれば、データベースがそれに従います。
packages/db/src/schema.tsで、「Message(メッセージ)」とは何かを定義します。drizzle-zodを使っている点に注目してください。これにより、API用のバリデーションスキーマが自動的に作成されます。これぞ「DRY(Don't Repeat Yourself)」原則の実践です。
import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
// チャットボット用メッセージテーブル
export const Message = pgTable("message", (t) => ({
id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
role: t.varchar({ length: 20 }).notNull(), // 'user' または 'assistant'
content: t.text().notNull(),
createdAt: t.timestamp().defaultNow().notNull(),
}));
// テーブル定義からZodスキーマを自動生成
export const CreateMessageSchema = createInsertSchema(Message, {
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });
プッシュします:pnpm db:push。完了。これでデータベースが存在します。
何が起きたのか? DrizzleがTypeScriptの定義を見て、テーブルを作成しました。SQLは書いていません。マイグレーションファイルもありません。これがスキーマ駆動開発の魔法です。
確認したい場合は、pnpm db:studioを実行してください。https://local.drizzle.studioでWeb UIが開き、データを受け入れる準備ができたmessageテーブルが表示されます。
ステップ 4: 神経系 (tRPC)
ここが多くの人を驚かせる部分です。RESTやGraphQLでは、エンドポイント、型、フェッチャーを別々に定義する必要があります。しかしtRPCなら、バックエンドの関数がそのままフロントエンドの関数になります。
ユーザーのメッセージを保存し、履歴(AIにおいてコンテキストは王様です)を取得し、Geminiに送信して、返信を保存するプロシージャを作成します。
packages/api/src/router/chat.tsを作成します:
import type { TRPCRouterRecord } from "@trpc/server";
import { google } from "@ai-sdk/google";
import { generateText } from "ai";
import { z } from "zod/v4";
import { desc } from "@acme/db";
import { Message } from "@acme/db/schema";
import { publicProcedure } from "../trpc";
const SYSTEM_PROMPT = "You are a helpful AI assistant.";
export const chatRouter = {
sendChat: publicProcedure
.input(z.object({ content: z.string().min(1).max(10000) }))
.mutation(async ({ ctx, input }) => {
// 1. ユーザーメッセージを保存
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
// 2. コンテキストを取得 (最新10件)
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
// 3. Geminiに問い合わせ
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: SYSTEM_PROMPT,
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
// 4. AIの返信を保存
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
getMessages: publicProcedure.query(({ ctx }) =>
ctx.db.select().from(Message).orderBy(Message.createdAt),
),
clearMessages: publicProcedure.mutation(({ ctx }) => ctx.db.delete(Message)),
} satisfies TRPCRouterRecord;
packages/api/src/root.tsでルーターを登録します:
import { chatRouter } from "./router/chat";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
chat: chatRouter,
});
export type AppRouter = typeof appRouter;
このフローを見てください。直線的で、読みやすく、完全に型付けされています。データベースのスキーマを変更すれば、このコードは即座に赤くなります(エラーになります)。実行時のサプライズはありません。
なぜ .reverse() なのか? メッセージは降順(新しい順)で取得しますが、LLMは時系列順(古い順)を期待するからです。これは会話の混乱を防ぐための小さな、しかし重要な詳細です。
ステップ 5: インターフェース
apps/nextjs/src/app/chat/page.tsxで接続します。tRPCを使っているので、React Queryが無料でついてきます。useQueryがフェッチ、キャッシュ、ローディング状態を処理してくれるので、データ取得のためにuseEffectを書く必要は一切ありません。
(UXのために、一番下までスクロールさせるuseEffectだけは入れています)。
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { useTRPC } from "~/trpc/react";
export default function ChatPage() {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
const queryClient = useQueryClient();
// キャッシュ付きの自動データフェッチ
const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());
// 楽観的更新付きのミューテーション
const sendMsg = useMutation(
trpc.chat.sendChat.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.chat.pathFilter());
setInput("");
setLoading(false);
},
onError: (err) => {
console.error("Failed:", err);
setLoading(false);
},
}),
);
// 最新メッセージへ自動スクロール
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
setLoading(true);
sendMsg.mutate({ content: input.trim() });
};
return (
<div className="flex h-screen flex-col bg-gray-50">
{/* ヘッダー */}
<div className="border-b bg-white p-4">
<h1 className="text-xl font-bold">AI Chat</h1>
</div>
{/* メッセージ一覧 */}
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-4xl space-y-4">
{messages?.map((m: RouterOutputs["chat"]["getMessages"][number]) => (
<div key={m.id} className={m.role === "user" ? "text-right" : ""}>
<div
className={`inline-block rounded-2xl px-4 py-3 ${
m.role === "user"
? "bg-blue-500 text-white"
: "bg-white border shadow-sm"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
</div>
</div>
))}
{loading && (
<div className="flex gap-2">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.4s]" />
</div>
)}
<div ref={endRef} />
</div>
</div>
{/* 入力フォーム */}
<form onSubmit={handleSubmit} className="border-t bg-white p-4">
<div className="mx-auto flex max-w-4xl gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 rounded-lg border px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none"
disabled={loading}
/>
<button
type="submit"
disabled={!input.trim() || loading}
className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
ホームページも忘れずに。apps/nextjs/src/app/page.tsxを更新します:
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-blue-500 to-blue-700">
<div className="text-center text-white">
<h1 className="text-5xl font-bold">AI Chatbot</h1>
<p className="mt-4 text-xl">Built with T3 Turbo + Gemini</p>
<Link
href="/chat"
className="mt-8 inline-block rounded-full bg-white px-10 py-3 font-semibold text-blue-600 hover:bg-gray-100 transition"
>
Start Chatting
</Link>
</div>
</main>
);
}
pnpm devを実行し、http://localhost:3000にアクセスしてください。「Start Chatting」をクリックすれば、動くAIチャットボットの完成です。
tRPCの魔法:APIフェッチを一度も書いていないことに気づきましたか? fetch()呼び出しも、URL文字列も、手動のエラー処理もありません。TypeScriptはsendMsg.mutate()が何を期待しているかを知っています。バックエンドの入力スキーマを変更すれば、フロントエンドでコンパイルエラーが発生します。これが未来です。
ステップ 6: 魂を吹き込む (Vibe Check)
一般的なアシスタントは退屈です。退屈なアシスタントは削除されます。LLMの素晴らしさは、優れたロールプレイヤーであることです。
ボットに強い意見を持たせると、エンゲージメントが10倍になることがわかりました。「あなたは役に立つAIです」とプロンプトするのではなく、**人格(ペルソナ)**をプロンプトするのです。
バックエンドを修正して、ペルソナを受け取れるようにしましょう。packages/api/src/router/chat.tsを更新します:
const PROMPTS = {
default: "You are a helpful AI assistant. Be concise and clear.",
luffy:
"You are Monkey D. Luffy from One Piece. You're energetic, optimistic, love meat and adventure. You often say 'I'm gonna be King of the Pirates!' Speak simply and enthusiastically.",
stark:
"You are Tony Stark (Iron Man). You're a genius inventor, witty, and sarcastic. You love technology and often mention Stark Industries. Call people 'kid' or 'buddy'. Be charming but arrogant.",
};
export const chatRouter = {
sendChat: publicProcedure
.input(
z.object({
content: z.string().min(1).max(10000),
character: z.enum(["default", "luffy", "stark"]).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// 人格を選択
const systemPrompt = PROMPTS[input.character || "default"];
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: systemPrompt, // ← 動的プロンプト
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
// ... 残りはそのまま
};
フロントエンドを更新して、キャラクター選択を渡せるようにします:
// ChatPageコンポーネント内で、キャラクター用のstateを追加
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");
// ミューテーション呼び出しを更新
sendMsg.mutate({ content: input.trim(), character });
// 入力欄の前にドロップダウンを追加:
<select
value={character}
onChange={(e) => setCharacter(e.target.value as any)}
className="rounded-lg border px-3 py-2"
>
<option value="default">🤖 Default</option>
<option value="luffy">👒 Luffy</option>
<option value="stark">🦾 Tony Stark</option>
</select>
これで、単なるチャットボットではなく、キャラクター対話プラットフォームを作ったことになります。それが「プロダクト」です。
あなたが本当に知りたい技術的な詳細
なぜPrismaを使わないのか?
Prismaも素晴らしいですが、Drizzleの方が速いです。クエリパフォーマンスで2〜3倍の差が出ます。一人の創業者にとって、ミリ秒単位の積み重ねは重要です。さらに、DrizzleのSQLライクな構文は、頭の切り替えコスト(メンタルオーバーヘッド)を減らしてくれます。
ストリーミングレスポンスはどうする?
Vercel AI SDKはストリーミングを標準でサポートしています。generateTextをstreamTextに置き換え、フロントエンドでuseChatフックを使ってください。今回はチュートリアルなので、リクエスト/レスポンス形式の方がシンプルだと思い省略しました。しかし本番環境では? すべてストリーミングにすべきです。合計時間が同じでも、ユーザーはストリーミングの方が「速い」と感じます。
コンテキストウィンドウの管理
今は最新の10件を取得していますが、これはいずれ破綻します。本格的なプロダクトを作るなら、トークンカウンターを実装し、履歴を動的に調整してください。AI SDKにはこのためのユーティリティがあります。
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // コスト管理
// ...
});
データベース接続プーリング
ローカルのPostgresは開発用には十分です。本番環境では、Vercel PostgresやSupabaseを使ってください。これらは接続プーリングを自動的に処理してくれます。サーバーレス環境でのデータベース接続管理は罠です。自分で管理しようとしてはいけません。
実践的なテイクアウェイ
これを読んでコードを書きたくてうずうずしているなら、私からのアドバイスは以下の通りです:
- ゼロから作らないこと。 ボイラープレートは勢いの敵です。T3 Turboのような足場(スキャフォールディング)を使ってください。
- 型安全性はスピードだ。 最初の一時間は遅く感じるかもしれませんが、その後の10年間を速くしてくれます。デモ中に起きがちなバグを事前に防いでくれます。
- コンテキストが鍵。 履歴のないチャットボットは、ただの豪華な検索バーです。常に直近のメッセージをLLMに渡してください。
- 機能より人格。 10個の余分な機能を持つ一般的なボットより、トニー・スタークのように話すボットの方がエンゲージメントを得られます。
泥臭い現実
これを作るのは、すべてが順風満帆だったわけではありません。最初はデータベース接続文字列を間違えて、Drizzleに怒られながら20分を無駄にしました。また、最初に履歴を送りすぎてGeminiのレート制限にも引っかかりました(教訓:常に.limit(5)くらいから始めて、徐々にスケールさせること)。
ローディングアニメーション? CSSアニメーションは2024年になってもなぜか黒魔術のようで、正しく動かすのに3回やり直しました。
しかし重要なのは、堅牢なスタックを使っていたおかげで、これらは「論理的」な問題であって、「構造的」な問題ではなかったということです。土台は揺らぎませんでした。抽象化を間違えたせいでAPI全体をリファクタリングする必要もありませんでした。
リリースせよ (Ship It)
私たちは今、「構築」の黄金時代に生きています。ツールは強力で、AIは賢く、参入障壁はかつてないほど低くなっています。
コードは手元にあります。スタックもあります。トレードオフも理解しました。
さあ、存在すべきではなかったものを作り、夕食前にリリースしましょう。
総構築時間: ~2時間 実際に書いたコード行数: ~200行 本番環境でのバグ: 0 (今のところ)
T3 Stack + Geminiは単に速いだけではありません。良い意味で退屈です。サプライズなし。「私のマシンでは動くのに」もなし。ただひたすら、作るだけ。
Happy coding.
リソース:
シェア

Feng Liu
shenjian8628@gmail.com