【第1回】T3 TurboとGeminiでチャットボットを構築する方法

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

【第1回】T3 TurboとGeminiでチャットボットを構築する方法
Feng LiuFeng Liu
2025年12月12日

タイトル:T3 StackとGeminiで爆速開発:チャットボット構築完全ガイド 抜粋:複雑さはスタートアップの敵です。T3 Turbo、Drizzle、Google Geminiを使って、アイデアからデプロイまで数時間で完了する「Vibe Coding」な開発フローを紹介します。


複雑さは、初期段階のスタートアップにとって「静かなる殺人者」です。「トニー・スタークのように話すチャットボットを作りたい」というシンプルなアイデアから始まったはずが、3週間後にはまだWebpackの設定をいじっていたり、Dockerコンテナと格闘していたり、誰も使っていない認証フローのデバッグに追われていたりします。

これは、才能あふれる優秀なエンジニアたちが何度も陥る罠です。私たちはツールを愛しています。最適化が大好きです。しかし、スタートアップというゲームにおいて、「プロダクトを世に出すこと(Shipping)」こそが唯一の重要な指標なのです。

最近のTypeScriptエコシステムを見ていないなら、驚くかもしれません。バラバラのAPIをつぎはぎして、なんとか動くように祈る日々は、ほぼ過去のものとなりました。私たちは今、「Vibe Coder(バイブコーダー)」の時代に突入しています。アイデアからデプロイされたプロダクトまでの距離は、スプリント単位ではなく、時間単位で測られる時代です。

今日は、まるでチートコードのようなスタックを紹介します。Create T3 TurboGoogle 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.jstRPCPostgreSQLを選択してください。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はストリーミングを標準でサポートしています。generateTextstreamTextに置き換え、フロントエンドで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 PostgresSupabaseを使ってください。これらは接続プーリングを自動的に処理してくれます。サーバーレス環境でのデータベース接続管理は罠です。自分で管理しようとしてはいけません。

実践的なテイクアウェイ

これを読んでコードを書きたくてうずうずしているなら、私からのアドバイスは以下の通りです:

  1. ゼロから作らないこと。 ボイラープレートは勢いの敵です。T3 Turboのような足場(スキャフォールディング)を使ってください。
  2. 型安全性はスピードだ。 最初の一時間は遅く感じるかもしれませんが、その後の10年間を速くしてくれます。デモ中に起きがちなバグを事前に防いでくれます。
  3. コンテキストが鍵。 履歴のないチャットボットは、ただの豪華な検索バーです。常に直近のメッセージをLLMに渡してください。
  4. 機能より人格。 10個の余分な機能を持つ一般的なボットより、トニー・スタークのように話すボットの方がエンゲージメントを得られます。

泥臭い現実

これを作るのは、すべてが順風満帆だったわけではありません。最初はデータベース接続文字列を間違えて、Drizzleに怒られながら20分を無駄にしました。また、最初に履歴を送りすぎてGeminiのレート制限にも引っかかりました(教訓:常に.limit(5)くらいから始めて、徐々にスケールさせること)。

ローディングアニメーション? CSSアニメーションは2024年になってもなぜか黒魔術のようで、正しく動かすのに3回やり直しました。

しかし重要なのは、堅牢なスタックを使っていたおかげで、これらは「論理的」な問題であって、「構造的」な問題ではなかったということです。土台は揺らぎませんでした。抽象化を間違えたせいでAPI全体をリファクタリングする必要もありませんでした。

リリースせよ (Ship It)

私たちは今、「構築」の黄金時代に生きています。ツールは強力で、AIは賢く、参入障壁はかつてないほど低くなっています。

コードは手元にあります。スタックもあります。トレードオフも理解しました。

さあ、存在すべきではなかったものを作り、夕食前にリリースしましょう。

総構築時間: ~2時間 実際に書いたコード行数: ~200行 本番環境でのバグ: 0 (今のところ)

T3 Stack + Geminiは単に速いだけではありません。良い意味で退屈です。サプライズなし。「私のマシンでは動くのに」もなし。ただひたすら、作るだけ。

Happy coding.


リソース:

完全なコード: github.com/giftedunicorn/my-chatbot

シェア

Feng Liu

Feng Liu

shenjian8628@gmail.com

【第1回】T3 TurboとGeminiでチャットボットを構築する方法 | Feng Liu