Phần 1: Cách xây dựng Chatbot với T3 Turbo & Gemini

Hầu hết các founder đều sa lầy vào "địa ngục setup". Tôi vừa build xong một AI chatbot hoàn toàn type-safe chỉ trong một buổi chiều. Đây là tech stack chi tiết—Next.js, tRPC và Gemini—kèm theo code mẫu để bạn tự triển khai ngay.

Phần 1: Cách xây dựng Chatbot với T3 Turbo & Gemini
Feng LiuFeng Liu
12 tháng 12, 2025

Title: Xây Dựng Chatbot AI "Có Hồn" Trong 2 Giờ: T3 Stack + Gemini

Sự phức tạp là kẻ sát nhân thầm lặng của các startup giai đoạn đầu. Bạn bắt đầu với một ý tưởng đơn giản—"Tôi muốn một chatbot nói chuyện như Tony Stark"—và ba tuần sau, bạn vẫn đang loay hoay cấu hình Webpack, vật lộn với các container Docker, hoặc debug một luồng xác thực mà chưa có ai sử dụng.

Đó là một cái bẫy mà tôi đã thấy những kỹ sư cực kỳ tài năng rơi vào hết lần này đến lần khác. Chúng ta yêu thích các công cụ của mình. Chúng ta thích tối ưu hóa. Nhưng trong cuộc chơi startup, ship sản phẩm là chỉ số duy nhất quan trọng.

Nếu gần đây bạn chưa xem qua hệ sinh thái TypeScript hiện đại, bạn có thể sẽ ngạc nhiên. Những ngày tháng chắp vá các API rời rạc và cầu nguyện cho chúng hoạt động ổn định đã lùi xa. Chúng ta đã bước vào kỷ nguyên của "Vibe Coder"—nơi khoảng cách giữa ý tưởng và sản phẩm được deploy chỉ tính bằng giờ, không phải bằng sprint.

Hôm nay, tôi sẽ hướng dẫn bạn đi qua một tech stack mang lại cảm giác như đang dùng "cheat code": Create T3 Turbo kết hợp với Google Gemini AI. Nó an toàn về kiểu dữ liệu (type-safe) từ database đến frontend, nhanh một cách khủng khiếp, và thú thật là nó mang lại niềm vui cho việc viết code.

Tại Sao Stack Này Lại Quan Trọng?

Có thể bạn đang nghĩ: "Feng Liu, tại sao lại là một stack khác? Tôi không thể dùng Python và Streamlit được sao?"

Được chứ, nếu là bản prototype (nguyên mẫu). Nhưng nếu bạn đang xây dựng một sản phẩm—thứ gì đó cần mở rộng, xử lý người dùng và duy trì trạng thái (state)—bạn cần một kiến trúc thực sự. Vấn đề là "kiến trúc thực sự" thường đồng nghĩa với "hàng tuần liền chỉ để viết code mẫu (boilerplate)."

T3 Stack (Next.js, tRPC, Tailwind) lật ngược kịch bản này. Nó mang lại cho bạn sự mạnh mẽ của một ứng dụng full-stack với tốc độ phát triển của một script đơn giản. Khi bạn thêm Drizzle ORM (nhẹ, giống SQL) và Google Gemini (nhanh, gói miễn phí hào phóng), bạn có trong tay bộ công cụ cho phép một founder độc lập (solo founder) vượt mặt cả một team mười người.

Hãy cùng xây dựng một cái gì đó thực tế nào.

Bước 1: Cài Đặt Bằng Một Câu Lệnh

Quên việc cấu hình thủ công ESLint và Prettier đi. Chúng ta sẽ sử dụng create-t3-turbo. Nó thiết lập cấu trúc monorepo, cực kỳ hoàn hảo vì nó tách biệt logic API khỏi frontend Next.js, giúp bạn sẵn sàng cho tương lai khi bạn chắc chắn sẽ muốn ra mắt ứng dụng di động React Native sau này.

pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install

Khi được hỏi, tôi đã chọn Next.js, tRPC, và PostgreSQL. Tôi bỏ qua Auth (xác thực) lúc này vì, nhắc lại nhé, chúng ta đang tối ưu cho việc ship sản phẩm, không phải sự hoàn hảo. Bạn có thể thêm NextAuth sau chỉ trong mười phút.

Cấu trúc monorepo bạn nhận được:

my-chatbot/
├── apps/nextjs/          # Web app của bạn
├── packages/
│   ├── api/              # tRPC routers (logic dùng chung)
│   ├── db/               # Database schema + Drizzle
│   └── ui/               # Các component dùng chung

Sự tách biệt này có nghĩa là logic API của bạn có thể được tái sử dụng trên web, mobile, hoặc thậm chí là các ứng dụng CLI. Tôi đã thấy nhiều team lãng phí hàng tháng trời để refactor (tái cấu trúc) chỉ vì họ bắt đầu với mọi thứ nhét chung vào một thư mục.

Bước 2: Bộ Não (Gemini)

OpenAI rất tuyệt, nhưng bạn đã thử Gemini Flash chưa? Nó nhanh đến kinh ngạc và giá cả thì cực kỳ cạnh tranh. Đối với giao diện chat nơi độ trễ sẽ giết chết trải nghiệm (vibe), tốc độ chính là một tính năng.

Tại sao chọn Gemini Flash thay vì GPT-3.5/4?

  • Tốc độ: ~800ms so với 2-3s thời gian phản hồi
  • Chi phí: Rẻ hơn 60 lần so với GPT-4
  • Context: Cửa sổ ngữ cảnh 1 triệu token (vâng, một triệu đấy)

Chúng ta cần AI SDK để chuẩn hóa việc giao tiếp với các LLM.

cd packages/api
pnpm add ai @ai-sdk/google

Thiết lập file .env của bạn ở thư mục gốc dự án. Đừng suy nghĩ quá nhiều về database ở local; một instance Postgres cục bộ là đủ dùng.

POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"

Mẹo nhỏ: Lấy API key Gemini của bạn tại https://aistudio.google.com/app/apikey. Gói miễn phí hào phóng một cách vô lý—60 request mỗi phút. Bạn sẽ đạt được Product-Market Fit trước khi chạm đến giới hạn rate limit này.

Bước 3: Định Nghĩa Thực Tại (Schema)

Đây là lúc Drizzle tỏa sáng. Ngày xưa, bạn phải viết migration bằng tay. Bây giờ, bạn định nghĩa schema bằng TypeScript, và database sẽ tuân lệnh.

Trong packages/db/src/schema.ts, chúng ta định nghĩa một "Message" là gì. Hãy chú ý cách chúng ta dùng drizzle-zod. Nó tự động tạo ra các schema validation cho API của chúng ta. Đây chính là nguyên tắc "Don't Repeat Yourself" (Đừng lặp lại chính mình) trong thực tế.

import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";

// Bảng Message cho chatbot
export const Message = pgTable("message", (t) => ({
  id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
  role: t.varchar({ length: 20 }).notNull(), // 'user' hoặc 'assistant'
  content: t.text().notNull(),
  createdAt: t.timestamp().defaultNow().notNull(),
}));

// Zod schema được tạo tự động từ định nghĩa bảng
export const CreateMessageSchema = createInsertSchema(Message, {
  role: z.enum(["user", "assistant"]),
  content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });

Đẩy nó lên: pnpm db:push. Xong. Database của bạn giờ đã tồn tại.

Chuyện gì vừa xảy ra? Drizzle nhìn vào định nghĩa TypeScript của bạn và tạo bảng. Không cần viết SQL. Không cần file migration. Đây là ma thuật của việc phát triển dựa trên schema (schema-driven development).

Nếu muốn kiểm tra, hãy chạy: pnpm db:studio và bạn sẽ thấy giao diện web tại https://local.drizzle.studio với bảng message nằm đó, sẵn sàng nhận dữ liệu.

Bước 4: Hệ Thần Kinh (tRPC)

Đây là phần thường khiến mọi người "bùng nổ" tâm trí. Với REST hay GraphQL, bạn phải định nghĩa endpoint, type, và fetcher riêng biệt. Với tRPC, hàm backend chính là hàm frontend của bạn.

Chúng ta sẽ tạo một thủ tục (procedure) để lưu tin nhắn người dùng, lấy lịch sử (context là vua trong AI), gửi nó đến Gemini, và lưu câu trả lời.

Tạo file 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. Lưu tin nhắn người dùng
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. Lấy Context (10 tin nhắn gần nhất)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. Hỏi 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. Lưu câu trả lời của 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;

Đăng ký router trong 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;

Hãy nhìn vào luồng dữ liệu đó. Nó tuyến tính, dễ đọc và được định kiểu đầy đủ (fully typed). Nếu bạn thay đổi schema database, đoạn code này sẽ báo đỏ ngay lập tức. Không còn những bất ngờ khi chạy (runtime surprises).

Tại sao lại có .reverse()? Chúng ta truy vấn tin nhắn theo thứ tự giảm dần (mới nhất trước) nhưng LLM mong đợi thứ tự thời gian (cũ nhất trước). Đó là một chi tiết nhỏ giúp tránh những cuộc hội thoại khó hiểu.

Modular Architecture Visualization

Bước 5: Giao Diện (Interface)

Trong apps/nextjs/src/app/chat/page.tsx, chúng ta sẽ kết nối mọi thứ. Vì đang dùng tRPC, chúng ta được hưởng React Query miễn phí. useQuery xử lý việc fetch dữ liệu, caching, và trạng thái loading mà chúng ta không cần viết một dòng useEffect nào để fetch dữ liệu.

(Tôi chỉ dùng useEffect để cuộn xuống dưới cùng—vì UX rất quan trọng).

"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();

  // Tự động fetch dữ liệu với caching
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutation với cập nhật lạc quan (optimistic updates)
  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);
      },
    }),
  );

  // Tự động cuộn xuống tin nhắn mới nhất
  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">
      {/* Header */}
      <div className="border-b bg-white p-4">
        <h1 className="text-xl font-bold">AI Chat</h1>
      </div>

      {/* Messages */}
      <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>

      {/* Input */}
      <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>
  );
}

Đừng quên trang chủ. Cập nhật 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>
  );
}

Chạy pnpm dev và truy cập http://localhost:3000. Nhấn "Start Chatting" và bạn đã có một AI chatbot hoạt động ngon lành.

Ma thuật của tRPC: Hãy để ý xem, chúng ta chưa hề viết một lệnh API fetch nào? Không có fetch(), không có chuỗi URL, không xử lý lỗi thủ công. TypeScript biết sendMsg.mutate() mong đợi điều gì. Nếu bạn thay đổi input schema ở backend, frontend sẽ báo lỗi biên dịch (compile error). Đây chính là tương lai.

Bước 6: Thổi Hồn (Kiểm Tra "Vibe")

Một trợ lý chung chung thì rất nhàm chán. Một trợ lý chung chung sẽ bị xóa bỏ. Vẻ đẹp của LLM là chúng nhập vai cực xuất sắc.

Tôi nhận thấy rằng việc tạo cho bot của bạn một quan điểm mạnh mẽ sẽ khiến nó hấp dẫn hơn gấp 10 lần. Đừng chỉ prompt "Bạn là người hữu ích." Hãy prompt một tính cách.

Hãy sửa đổi backend để chấp nhận một nhân cách (persona). Cập nhật 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 }) => {
      // Chọn tính cách
      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, // ← Prompt động
        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();
    }),

  // ... phần còn lại giữ nguyên
};

Cập nhật frontend để truyền lựa chọn nhân vật:

// Trong component ChatPage, thêm state cho character
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Cập nhật lệnh gọi mutation
sendMsg.mutate({ content: input.trim(), character });

// Thêm dropdown trước ô input:
<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>

Bây giờ bạn không chỉ xây dựng một chatbot; bạn đã xây dựng một nền tảng tương tác nhân vật. Đó mới là một sản phẩm.

Những Chi Tiết Kỹ Thuật Mà Bạn Thực Sự Quan Tâm

Tại sao không dùng Prisma?

Prisma rất tuyệt, nhưng Drizzle nhanh hơn. Chúng ta đang nói về hiệu suất truy vấn gấp 2-3 lần. Khi bạn là một solo founder, mỗi mili-giây đều cộng dồn. Thêm vào đó, cú pháp giống SQL của Drizzle giúp giảm bớt gánh nặng tư duy.

Còn về streaming phản hồi?

Vercel AI SDK hỗ trợ streaming ngay từ đầu. Thay thế generateText bằng streamText và sử dụng hook useChat ở frontend. Tôi đã bỏ qua nó ở đây vì trong một bài hướng dẫn, request/response đơn giản hơn. Nhưng trong production? Hãy stream mọi thứ. Người dùng cảm thấy streaming "nhanh hơn" ngay cả khi tổng thời gian là như nhau.

Quản lý Context window

Hiện tại chúng ta đang lấy 10 tin nhắn cuối cùng. Cách này ổn cho đến khi nó không còn ổn nữa. Nếu bạn đang xây dựng một sản phẩm nghiêm túc, hãy triển khai bộ đếm token và điều chỉnh lịch sử một cách linh hoạt. AI SDK có các tiện ích cho việc này.

import { anthropic } from "@ai-sdk/anthropic";

const { text } = await generateText({
  model: anthropic("claude-3-5-sonnet-20241022"),
  maxTokens: 1000, // Kiểm soát chi phí
  // ...
});

Database connection pooling

Postgres local ổn cho dev. Đối với production, hãy dùng Vercel Postgres hoặc Supabase. Chúng xử lý connection pooling tự động. Serverless + kết nối database là một cái bẫy—đừng tự quản lý nó.

Bài Học Thực Tế

Nếu bạn đang đọc bài này và cảm thấy "ngứa tay" muốn code, đây là lời khuyên của tôi:

  1. Đừng bắt đầu từ con số 0. Boilerplate là kẻ thù của đà phát triển. Hãy dùng T3 Turbo hoặc các khung sườn tương tự.
  2. Type safety chính là tốc độ. Cảm giác sẽ chậm hơn trong giờ đầu tiên, nhưng nhanh hơn trong mười năm tiếp theo. Nó bắt được những con bug thường hay xuất hiện ngay lúc demo.
  3. Context là chìa khóa. Một chatbot không có lịch sử chỉ là một thanh tìm kiếm màu mè. Luôn gửi vài tin nhắn gần nhất cho LLM.
  4. Tính cách > tính năng. Một con bot nói chuyện như Tony Stark sẽ thu hút tương tác hơn một con bot chung chung với 10 tính năng thừa thãi.

Thực Tế Hỗn ĐộN

Việc xây dựng cái này không phải lúc nào cũng thuận buồm xuôi gió. Ban đầu tôi đã làm hỏng chuỗi kết nối database và mất 20 phút tự hỏi tại sao Drizzle lại "la ó" tôi. Tôi cũng chạm rate limit trên Gemini vì ban đầu gửi quá nhiều lịch sử (bài học: luôn bắt đầu với .limit(5) và tăng dần lên).

Cái animation loading? Tôi mất ba lần thử mới làm đúng vì CSS animation, bằng cách nào đó, vẫn là "ma thuật đen" trong năm 2024.

Nhưng vấn đề là: vì tôi đang sử dụng một stack mạnh mẽ, đó chỉ là những vấn đề về logic, không phải vấn đề về cấu trúc. Nền tảng vẫn vững chắc. Tôi chưa bao giờ phải refactor toàn bộ API chỉ vì chọn sai sự trừu tượng hóa.

Ship Nó Đi

Chúng ta đang sống trong thời kỳ hoàng kim của việc xây dựng sản phẩm. Các công cụ rất mạnh mẽ, AI rất thông minh, và rào cản gia nhập chưa bao giờ thấp hơn thế.

Bạn đã có code rồi. Bạn đã có stack. Bạn hiểu các sự đánh đổi.

Hãy đi xây dựng một thứ gì đó đáng lẽ không nên tồn tại, và ship nó trước bữa tối.

Tổng thời gian build: ~2 giờ Số dòng code thực tế đã viết: ~200 Số bug gặp phải trong production: 0 (cho đến nay)

T3 stack + Gemini không chỉ nhanh—nó còn nhàm chán theo cách tốt nhất. Không bất ngờ. Không có chuyện "chạy được trên máy tôi mà." Chỉ là xây dựng thôi.

Chúc bạn code vui vẻ.


Tài nguyên:

Full code: github.com/giftedunicorn/my-chatbot

Excerpt: Sự phức tạp là kẻ sát nhân thầm lặng của các startup. Feng Liu hướng dẫn bạn xây dựng một chatbot AI có "nhân cách" chỉ trong 2 giờ sử dụng T3 Stack (Next.js, tRPC) và Google Gemini. Khám phá cách "Vibe Coder" hiện đại ship sản phẩm với tốc độ ánh sáng.

Chia sẻ

Feng Liu

Feng Liu

shenjian8628@gmail.com

Phần 1: Cách xây dựng Chatbot với T3 Turbo & Gemini | Feng Liu