Parte 1: Como criar um Chatbot com T3 Turbo e Gemini

A maioria dos founders fica travada no 'inferno do setup'. Eu criei um chatbot de IA totalmente type-safe em uma tarde. Aqui está a stack exata — Next.js, tRPC e Gemini — e o código para você fazer o mesmo.

Parte 1: Como criar um Chatbot com T3 Turbo e Gemini
Feng LiuFeng Liu
12 de dezembro de 2025

Title: Do Zero ao Deploy: Construindo um Chatbot de IA com T3 Turbo e Gemini

Content: A complexidade é o assassino silencioso das startups em estágio inicial. Você começa com uma ideia simples — "Eu quero um chatbot que fale como o Tony Stark" — e três semanas depois, você ainda está configurando o Webpack, lutando com containers Docker ou debugando um fluxo de autenticação que ninguém usou ainda.

É uma armadilha na qual vejo engenheiros brilhantes caírem repetidamente. Nós amamos nossas ferramentas. Amamos otimizar. Mas no jogo das startups, shippar (entregar) é a única métrica que importa.

Se você não olhou para o ecossistema moderno de TypeScript ultimamente, pode se surpreender. Os dias de costurar APIs díspares e rezar para que elas se mantenham unidas ficaram, em grande parte, para trás. Entramos na era do "Vibe Coder" — onde a distância entre uma ideia e um produto implantado é medida em horas, não em sprints.

Hoje, vou guiá-lo através de uma stack que parece um cheat code: Create T3 Turbo combinado com a IA Gemini do Google. É type-safe (seguro em tipagem) do banco de dados ao frontend, é ridiculamente rápido e, honestamente, traz a alegria de volta à programação.

Por Que Esta Stack Importa

Você pode estar pensando: "Feng, por que outra stack? Não posso simplesmente usar Python e Streamlit?"

Claro, para um protótipo. Mas se você está construindo um produto — algo que precisa escalar, lidar com usuários e manter estado — você precisa de uma arquitetura real. O problema é que "arquitetura real" geralmente significa "semanas de boilerplate".

A T3 Stack (Next.js, tRPC, Tailwind) inverte esse roteiro. Ela te dá a robustez de uma aplicação full-stack com a velocidade de desenvolvimento de um script. Quando você adiciona Drizzle ORM (leve, parecido com SQL) e Google Gemini (rápido, nível gratuito generoso), você tem um kit de ferramentas que permite a um fundador solo superar uma equipe de dez pessoas.

Vamos construir algo real.

Passo 1: A Configuração de Um Comando

Esqueça a configuração manual do ESLint e Prettier. Vamos usar o create-t3-turbo. Isso configura uma estrutura de monorepo que é perfeita porque separa sua lógica de API do seu frontend Next.js, preparando você para o futuro quando inevitavelmente lançar um aplicativo móvel React Native mais tarde.

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

Quando perguntado, selecionei Next.js, tRPC e PostgreSQL. Pulei a Autenticação (Auth) por enquanto porque, novamente, estamos otimizando para o envio, não para a perfeição. Você pode adicionar o NextAuth mais tarde em dez minutos.

A estrutura de monorepo que você recebe:

my-chatbot/
├── apps/nextjs/          # Seu web app
├── packages/
│   ├── api/              # Routers tRPC (lógica compartilhada)
│   ├── db/               # Schema do Banco de Dados + Drizzle
│   └── ui/               # Componentes compartilhados

Essa separação significa que sua lógica de API pode ser reutilizada na web, mobile ou até mesmo em apps CLI. Já vi equipes perderem meses refatorando porque começaram com tudo em uma única pasta.

Passo 2: O Cérebro (Gemini)

A OpenAI é ótima, mas você já experimentou o Gemini Flash? É incrivelmente rápido e o preço é agressivo. Para uma interface de chat onde a latência mata a vibe, velocidade é uma feature.

Por que Gemini Flash em vez de GPT-3.5/4?

  • Velocidade: ~800ms vs 2-3s de tempo de resposta
  • Custo: 60x mais barato que o GPT-4
  • Contexto: Janela de contexto de 1M de tokens (sim, um milhão)

Precisamos do AI SDK para padronizar a conversa com LLMs.

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

Configure seu .env na raiz do projeto. Não pense demais no banco de dados localmente; uma instância local do Postgres serve.

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

Dica pro: Pegue sua chave de API do Gemini em https://aistudio.google.com/app/apikey. O nível gratuito é absurdamente generoso — 60 requisições por minuto. Você atingirá o Product-Market Fit antes de atingir os limites de taxa.

Passo 3: Definir a Realidade (O Schema)

É aqui que o Drizzle brilha. Antigamente, você escrevia migrações à mão. Agora, você define seu schema em TypeScript, e o banco de dados obedece.

Em packages/db/src/schema.ts, definimos o que é uma "Message" (Mensagem). Note como usamos drizzle-zod? Isso cria automaticamente schemas de validação para nossa API. Este é o princípio "Don't Repeat Yourself" (DRY) em ação.

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

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

// Schema Zod gerado automaticamente a partir da definição da tabela
export const CreateMessageSchema = createInsertSchema(Message, {
  role: z.enum(["user", "assistant"]),
  content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });

Envie as alterações: pnpm db:push. Feito. Seu banco de dados agora existe.

O que acabou de acontecer? O Drizzle olhou para sua definição TypeScript e criou a tabela. Nenhum SQL escrito. Nenhum arquivo de migração. Essa é a magia do desenvolvimento guiado por schema.

Se quiser verificar, rode: pnpm db:studio e você verá uma UI web em https://local.drizzle.studio com sua tabela message lá, pronta para receber dados.

Passo 4: O Sistema Nervoso (tRPC)

Esta é a parte que geralmente explode a cabeça das pessoas. Com REST ou GraphQL, você tem que definir endpoints, tipos e fetchers separadamente. Com tRPC, sua função de backend é sua função de frontend.

Estamos criando um procedimento que salva a mensagem do usuário, pega o histórico (contexto é rei em IA), envia para o Gemini e salva a resposta.

Crie 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. Salvar Mensagem do Usuário
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. Obter Contexto (Últimas 10 mensagens)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. Perguntar ao 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. Salvar Resposta da IA
      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;

Registre o router em 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;

Olhe para esse fluxo. É linear, legível e totalmente tipado. Se você mudar o schema do banco de dados, este código fica vermelho imediatamente. Sem surpresas em tempo de execução.

Por que o .reverse()? Consultamos as mensagens em ordem decrescente (mais novas primeiro), mas as LLMs esperam ordem cronológica (mais antigas primeiro). É um pequeno detalhe que evita conversas confusas.

Visualização da Arquitetura Modular

Passo 5: A Interface

Em apps/nextjs/src/app/chat/page.tsx, nós conectamos tudo. Como estamos usando tRPC, ganhamos o React Query de graça. O useQuery lida com o fetching, cache e estados de carregamento sem escrevermos um único useEffect para busca de dados.

(Incluí um useEffect apenas para rolar para o final — porque UX importa).

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

  // Busca automática de dados com cache
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutation com atualizações otimistas
  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 para a última mensagem
  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>
  );
}

Não esqueça a página inicial. Atualize 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>
  );
}

Rode pnpm dev e visite http://localhost:3000. Clique em "Start Chatting" e você tem um chatbot de IA funcionando.

A magia do tRPC: Note como nunca escrevemos um fetch de API? Nenhuma chamada fetch(), nenhuma string de URL, nenhum tratamento de erro manual. O TypeScript sabe o que sendMsg.mutate() espera. Se você mudar o schema de entrada do backend, seu frontend vai lançar um erro de compilação. Este é o futuro.

Passo 6: Injetando Alma (O Teste de "Vibe")

Um assistente genérico é chato. Um assistente genérico é deletado. A beleza das LLMs é que elas são excelentes em interpretar papéis.

Descobri que dar ao seu bot uma opinião forte o torna 10x mais engajador. Não use apenas o prompt "Você é útil". Peça por uma personalidade.

Vamos modificar o backend para aceitar uma persona. Atualize 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 }) => {
      // Escolha a personalidade
      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 dinâmico
        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();
    }),

  // ... o resto permanece igual
};

Atualize o frontend para passar a seleção de personagem:

// No componente ChatPage, adicione estado para o personagem
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Atualize a chamada da mutation
sendMsg.mutate({ content: input.trim(), character });

// Adicione um dropdown antes do 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>

Agora você não construiu apenas um chatbot; você construiu uma plataforma de interação com personagens. Isso é um produto.

Os Detalhes Técnicos Que Você Realmente Se Importa

Por que não usar apenas o Prisma?

O Prisma é ótimo, mas o Drizzle é mais rápido. Estamos falando de 2-3x de performance em queries. Quando você é um fundador solo, cada milissegundo compõe. Além disso, a sintaxe parecida com SQL do Drizzle significa menos carga mental.

E sobre respostas em streaming?

O Vercel AI SDK suporta streaming nativamente. Substitua generateText por streamText e use o hook useChat no frontend. Pulei isso aqui porque, para um tutorial, request/response é mais simples. Mas em produção? Faça streaming de tudo. Os usuários percebem o streaming como "mais rápido" mesmo quando o tempo total é o mesmo.

Gerenciamento da janela de contexto

No momento, estamos pegando as últimas 10 mensagens. Isso funciona até não funcionar mais. Se você está construindo um produto sério, implemente um contador de tokens e ajuste o histórico dinamicamente. O AI SDK tem utilitários para isso.

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

const { text } = await generateText({
  model: anthropic("claude-3-5-sonnet-20241022"),
  maxTokens: 1000, // Controle custos
  // ...
});

Connection pooling do banco de dados

Postgres local é bom para dev. Para produção, use Vercel Postgres ou Supabase. Eles lidam com connection pooling automaticamente. Serverless + conexões de banco de dados é uma armadilha — não gerencie isso você mesmo.

Lições Práticas

Se você está lendo isso e sentindo aquela coceira para codar, aqui está meu conselho:

  1. Não comece do zero. Boilerplate é o inimigo do momentum. Use T3 Turbo ou uma estrutura similar.
  2. Type safety é velocidade. Parece mais lento na primeira hora, e mais rápido pelos próximos dez anos. Ele pega os bugs que geralmente acontecem durante uma demo.
  3. Contexto é chave. Um chatbot sem histórico é apenas uma barra de pesquisa chique. Sempre passe as últimas mensagens para a LLM.
  4. Personalidade > funcionalidades. Um bot que soa como o Tony Stark terá mais engajamento do que um bot genérico com 10 funcionalidades extras.

A Realidade Bagunçada

Construir isso não foi tudo um mar de rosas. Inicialmente, errei a string de conexão do banco de dados e passei 20 minutos me perguntando por que o Drizzle estava gritando comigo. Também atingi um limite de taxa no Gemini porque estava enviando muito histórico inicialmente (lição: sempre comece com .limit(5) e escale).

A animação de carregamento? Isso me levou três tentativas para acertar porque animações CSS ainda são, de alguma forma, magia negra em 2024.

Mas aqui está o ponto: porque eu estava usando uma stack robusta, esses eram problemas de lógica, não problemas estruturais. A fundação se manteve firme. Eu nunca tive que refatorar a API inteira porque escolhi a abstração errada.

Shippe Isso

Estamos vivendo na era de ouro da construção. As ferramentas são poderosas, a IA é inteligente e a barreira de entrada nunca foi tão baixa.

Você tem o código agora. Você tem a stack. Você entende os tradeoffs.

Vá construir algo que não deveria existir, e shippe antes do jantar.

Tempo total de construção: ~2 horas Linhas de código real escritas: ~200 Bugs encontrados em produção: 0 (até agora)

A stack T3 + Gemini não é apenas rápida — é entediante da melhor maneira possível. Sem surpresas. Sem "funciona na minha máquina". Apenas construção.

Happy coding.


Recursos:

Código completo: github.com/giftedunicorn/my-chatbot

Excerpt: Aprenda a construir um chatbot de IA em horas, não semanas, usando a stack T3 Turbo e Google Gemini. Um guia prático do Feng Liu sobre como shippar rápido, com type safety e sem dores de cabeça de configuração.

Compartilhar

Feng Liu

Feng Liu

shenjian8628@gmail.com

Parte 1: Como criar um Chatbot com T3 Turbo e Gemini | Feng Liu