Часть 1: Как создать чат-бота на T3 Turbo и Gemini

Большинство фаундеров застревают в «аду настройки». Я собрал полностью типобезопасный AI-чатбот всего за один вечер. Делюсь полным стеком — Next.js, tRPC и Gemini — и кодом, чтобы вы могли повторить это сами.

Часть 1: Как создать чат-бота на T3 Turbo и Gemini
Feng LiuFeng Liu
12 декабря 2025 г.

Сложность — это тихий убийца стартапов на ранней стадии. Вы начинаете с простой идеи — «Я хочу чат-бота, который говорит как Тони Старк» — а три недели спустя вы все еще настраиваете Webpack, сражаетесь с Docker-контейнерами или отлаживаете аутентификацию, которой еще никто не пользовался.

Я видел, как в эту ловушку раз за разом попадают блестящие инженеры. Мы любим наши инструменты. Мы любим оптимизировать. Но в игре стартапов релиз — это единственная метрика, которая имеет значение.

Если вы давно не заглядывали в современную экосистему TypeScript, вы можете удивиться. Дни, когда мы сшивали разрозненные API и молились, чтобы они держались вместе, по большей части позади. Мы вошли в эру «Вайб-кодинга» (Vibe Coder) — когда расстояние между идеей и развернутым продуктом измеряется часами, а не спринтами.

Сегодня я покажу вам стек, который ощущается как чит-код: Create T3 Turbo в сочетании с Google Gemini AI. Он типобезопасен (type-safe) от базы данных до фронтенда, он смехотворно быстр и, честно говоря, возвращает радость в программирование.

Почему этот стек важен

Вы можете подумать: «Feng Liu, зачем еще один стек? Разве я не могу просто использовать Python и Streamlit?»

Конечно, для прототипа — можете. Но если вы строите продукт — что-то, что должно масштабироваться, обслуживать пользователей и сохранять состояние (state), — вам нужна настоящая архитектура. Проблема в том, что «настоящая архитектура» обычно означает «недели написания бойлерплейта».

T3 Stack (Next.js, tRPC, Tailwind) переворачивает этот сценарий. Он дает вам надежность фулстек-приложения со скоростью разработки скрипта. А когда вы добавляете Drizzle ORM (легковесная, SQL-подобная) и Google Gemini (быстрая, с щедрым бесплатным тарифом), у вас появляется инструментарий, позволяющий соло-фаундеру переиграть команду из десяти человек.

Давайте построим что-то настоящее.

Шаг 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 позже за десять минут.

Структура монорепозитория, которую вы получите:

my-chatbot/
├── apps/nextjs/          # Ваше веб-приложение
├── packages/
│   ├── api/              # Роутеры tRPC (общая логика)
│   ├── db/               # Схема БД + Drizzle
│   └── ui/               # Общие компоненты

Такое разделение означает, что логику вашего API можно переиспользовать в вебе, мобайле или даже в CLI-приложениях. Я видел команды, которые тратили месяцы на рефакторинг только потому, что начали сваливать всё в одну папку.

Шаг 2: Мозг (Gemini)

OpenAI — это круто, но пробовали ли вы Gemini Flash? Она невероятно быстрая, а цены агрессивные. Для чат-интерфейса, где задержка (latency) убивает весь вайб, скорость — это фича.

Почему Gemini Flash, а не GPT-3.5/4?

  • Скорость: ~800мс против 2-3с времени отклика
  • Стоимость: в 60 раз дешевле, чем GPT-4
  • Контекст: окно контекста в 1М токенов (да, один миллион)

Нам понадобится AI SDK, чтобы стандартизировать общение с LLM.

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"

Совет профи: Получите API ключ Gemini на https://aistudio.google.com/app/apikey. Бесплатный уровень абсурдно щедр — 60 запросов в минуту. Вы достигнете Product-Market Fit раньше, чем упретесь в лимиты.

Шаг 3: Определение реальности (Схема)

Здесь Drizzle сияет. В старые времена вы писали миграции вручную. Теперь вы определяете схему на TypeScript, и база данных подчиняется.

В packages/db/src/schema.ts мы определяем, что такое "Message" (Сообщение). Заметили, как мы используем drizzle-zod? Это автоматически создает схемы валидации для нашего API. Это принцип "Don't Repeat Yourself" (Не повторяйся) в действии.

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

// Message table for chatbot
export const Message = pgTable("message", (t) => ({
  id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
  role: t.varchar({ length: 20 }).notNull(), // 'user' or 'assistant'
  content: t.text().notNull(),
  createdAt: t.timestamp().defaultNow().notNull(),
}));

// Zod schema auto-generated from table definition
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. Никаких файлов миграций. Это магия разработки, управляемой схемой (schema-driven development).

Если хотите проверить, запустите: pnpm db:studio, и вы увидите веб-интерфейс по адресу https://local.drizzle.studio с вашей таблицей message, готовой к приему данных.

Шаг 4: Нервная система (tRPC)

Это та часть, которая обычно взрывает людям мозг. С REST или GraphQL вам нужно отдельно определять эндпоинты, типы и фетчеры. С tRPC ваша бэкенд-функция — это и есть ваша фронтенд-функция.

Мы создаем процедуру, которая сохраняет сообщение пользователя, берет историю (контекст — это король в ИИ), отправляет её в 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. Save User Message
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. Get Context (Last 10 messages)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. Ask 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. Save AI Reply
      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;

Посмотрите на этот поток. Он линейный, читаемый и полностью типизированный. Если вы измените схему базы данных, этот код немедленно покраснеет. Никаких сюрпризов во время выполнения (runtime).

Зачем нужен .reverse()? Мы запрашиваем сообщения в обратном порядке (сначала новые), но LLM ожидают хронологический порядок (сначала старые). Это крошечная деталь, которая предотвращает путаницу в разговоре.

Modular Architecture Visualization

Шаг 5: Интерфейс

В apps/nextjs/src/app/chat/page.tsx мы всё соединяем. Поскольку мы используем tRPC, мы получаем React Query бесплатно. useQuery обрабатывает получение данных, кэширование и состояния загрузки без написания нами единого useEffect для фетчинга данных.

(Я включил useEffect только для прокрутки вниз — потому что UX имеет значение).

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

  // Automatic data fetching with caching
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutation with 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);
      },
    }),
  );

  // Auto-scroll to latest message
  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>
  );
}

Не забудьте про главную страницу. Обновите 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: Вдыхаем душу (Проверка на «вайб»)

Обычный ассистент — это скучно. Обычного ассистента удаляют. Прелесть LLM в том, что они отличные ролевики.

Я обнаружил, что если дать вашему боту сильное мнение, это делает его в 10 раз более вовлекающим. Не просто пишите в промпте «Ты полезный помощник». Задайте промптом личность.

Давайте изменим бэкенд, чтобы он принимал персону. Обновите 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 }) => {
      // Pick the personality
      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, // ← Dynamic prompt
        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();
    }),

  // ... rest stays the same
};

Обновите фронтенд, чтобы передавать выбор персонажа:

// In ChatPage component, add state for character
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Update the mutation call
sendMsg.mutate({ content: input.trim(), character });

// Add a dropdown before the 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>

Теперь вы построили не просто чат-бота; вы построили платформу для взаимодействия с персонажами. А это уже продукт.

Технические детали, которые вас действительно волнуют

Почему просто не использовать Prisma?

Prisma хороша, но Drizzle быстрее. Мы говорим о производительности запросов в 2-3 раза выше. Когда вы соло-фаундер, каждая миллисекунда имеет накопительный эффект. Плюс, SQL-подобный синтаксис Drizzle означает меньшую ментальную нагрузку.

Что насчет стриминга ответов?

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, // Control costs
  // ...
});

Пул соединений с базой данных

Локальный Postgres подходит для разработки. Для продакшена используйте Vercel Postgres или Supabase. Они обрабатывают пулинг соединений автоматически. Serverless + соединения с базой данных — это ловушка, не пытайтесь управлять этим самостоятельно.

Практические выводы

Если вы читаете это и чувствуете зуд начать кодить, вот мой совет:

  1. Не начинайте с нуля. Бойлерплейт — враг инерции. Используйте T3 Turbo или похожие заготовки.
  2. Типобезопасность — это скорость. Первый час кажется, что это медленнее, но следующие десять лет — быстрее. Она отлавливает баги, которые обычно случаются во время демо.
  3. Контекст — это ключ. Чат-бот без истории — это просто модная строка поиска. Всегда передавайте последние несколько сообщений в LLM.
  4. Личность > фичи. Бот, который звучит как Тони Старк, получит больше вовлеченности, чем обычный бот с 10 дополнительными функциями.

Суровая реальность

Создание этого проекта не было сплошной гладкой дорогой. Сначала я напортачил со строкой подключения к базе данных и потратил 20 минут, гадая, почему Drizzle на меня кричит. Я также уперся в лимит запросов на Gemini, потому что изначально отправлял слишком много истории (урок: всегда начинайте с .limit(5) и масштабируйте).

Анимация загрузки? У меня ушло три попытки, чтобы сделать её правильно, потому что CSS-анимации в 2024 году всё еще остаются какой-то черной магией.

Но вот в чем дело: поскольку я использовал надежный стек, это были логические проблемы, а не структурные. Фундамент стоял крепко. Мне ни разу не пришлось рефакторить весь API, потому что я выбрал неправильную абстракцию.

Запускайте

Мы живем в золотой век созидания. Инструменты мощные, ИИ умен, а порог входа низок как никогда.

Теперь у вас есть код. У вас есть стек. Вы понимаете компромиссы.

Идите, постройте что-то, чего не должно существовать, и зарелизьте это до ужина.

Общее время сборки: ~2 часа Написано строк реального кода: ~200 Встречено багов в продакшене: 0 (пока что)

Стек T3 + Gemini не просто быстрый — он скучный в самом лучшем смысле. Никаких сюрпризов. Никакого «работает на моей машине». Просто строительство.

Удачного кодинга.


Ресурсы:

Полный код: github.com/giftedunicorn/my-chatbot

Поделиться

Feng Liu

Feng Liu

shenjian8628@gmail.com

Часть 1: Как создать чат-бота на T3 Turbo и Gemini | Feng Liu