Parte 1: Cómo crear un Chatbot con T3 Turbo y Gemini

La mayoría de los fundadores se estancan en el "infierno de la configuración". Yo acabo de construir un chatbot de IA totalmente *type-safe* en una tarde. Aquí tienes el stack exacto —Next.js, tRPC y Gemini— y el código para hacerlo tú mismo.

Parte 1: Cómo crear un Chatbot con T3 Turbo y Gemini
Feng LiuFeng Liu
12 de diciembre de 2025

Title: La complejidad es el asesino silencioso de las startups en etapa temprana Content: La complejidad es el asesino silencioso de las startups en etapa temprana. Empiezas con una idea simple —"Quiero un chatbot que hable como Tony Stark"— y tres semanas después, sigues configurando Webpack, peleando con contenedores de Docker o depurando un flujo de autenticación que nadie ha usado todavía.

Es una trampa en la que he visto caer a ingenieros brillantemente talentosos una y otra vez. Amamos nuestras herramientas. Nos encanta optimizar. Pero en el juego de las startups, lanzar (shipping) es la única métrica que importa.

Si no has echado un vistazo al ecosistema moderno de TypeScript últimamente, podrías sorprenderte. Los días de remendar APIs dispares y rezar para que se mantengan unidas han quedado atrás en gran medida. Hemos entrado en la era del "Vibe Coder" —donde la distancia entre una idea y un producto desplegado se mide en horas, no en sprints.

Hoy, voy a guiarte a través de un stack que se siente como un código de trucos: Create T3 Turbo combinado con la IA Gemini de Google. Es seguro en tipos (type-safe) desde la base de datos hasta el frontend, es ridículamente rápido y, honestamente, devuelve la alegría a la programación.

Por qué importa este Stack

Quizás estés pensando: "Feng, ¿por qué otro stack? ¿No puedo simplemente usar Python y Streamlit?"

Claro, para un prototipo. Pero si estás construyendo un producto —algo que necesita escalar, manejar usuarios y mantener estado— necesitas una arquitectura real. El problema es que "arquitectura real" usualmente significa "semanas de código repetitivo (boilerplate)".

El T3 Stack (Next.js, tRPC, Tailwind) cambia este guion. Te da la robustez de una aplicación full-stack con la velocidad de desarrollo de un script. Cuando añades Drizzle ORM (ligero, similar a SQL) y Google Gemini (rápido, con un nivel gratuito generoso), tienes un kit de herramientas que permite a un fundador en solitario superar a un equipo de diez.

Construyamos algo real.

Paso 1: La configuración de un solo comando

Olvídate de configurar manualmente ESLint y Prettier. Vamos a usar create-t3-turbo. Esto configura una estructura de monorepo que es perfecta porque separa tu lógica de API de tu frontend en Next.js, preparándote para el futuro cuando inevitablemente lances una aplicación móvil en React Native más adelante.

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

Cuando me preguntó, seleccioné Next.js, tRPC y PostgreSQL. Me salté la autenticación (Auth) por ahora porque, de nuevo, estamos optimizando para lanzar, no para perfeccionar. Puedes añadir NextAuth más tarde en diez minutos.

La estructura de monorepo que obtienes:

my-chatbot/
├── apps/nextjs/          # Tu aplicación web
├── packages/
│   ├── api/              # Routers tRPC (lógica compartida)
│   ├── db/               # Esquema de base de datos + Drizzle
│   └── ui/               # Componentes compartidos

Esta separación significa que tu lógica de API puede reutilizarse entre web, móvil o incluso aplicaciones CLI. He visto equipos perder meses refactorizando porque empezaron con todo en una sola carpeta.

Paso 2: El Cerebro (Gemini)

OpenAI es genial, pero ¿has probado Gemini Flash? Es increíblemente rápido y el precio es agresivo. Para una interfaz de chat donde la latencia mata la vibra, la velocidad es una característica (feature).

¿Por qué Gemini Flash sobre GPT-3.5/4?

  • Velocidad: ~800ms vs 2-3s de tiempo de respuesta
  • Costo: 60x más barato que GPT-4
  • Contexto: Ventana de contexto de 1M de tokens (sí, un millón)

Necesitamos el AI SDK para estandarizar la comunicación con los LLMs.

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

Configura tu .env en la raíz del proyecto. No te compliques demasiado con la base de datos localmente; una instancia local de Postgres está bien.

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

Consejo pro: Obtén tu clave API de Gemini en https://aistudio.google.com/app/apikey. El nivel gratuito es absurdamente generoso: 60 peticiones por minuto. Alcanzarás el ajuste producto-mercado (Product-Market Fit) antes de llegar a los límites de velocidad.

Paso 3: Definir la Realidad (El Esquema)

Aquí es donde brilla Drizzle. En los viejos tiempos, escribías migraciones a mano. Ahora, defines tu esquema en TypeScript y la base de datos obedece.

En packages/db/src/schema.ts, definimos qué es un "Mensaje". ¿Notas cómo usamos drizzle-zod? Esto crea automáticamente esquemas de validación para nuestra API. Este es el principio "Don't Repeat Yourself" (No te repitas) en acción.

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 });

Empújalo: pnpm db:push. Hecho. Tu base de datos ahora existe.

¿Qué acaba de pasar? Drizzle miró tu definición en TypeScript y creó la tabla. Nada de SQL escrito. Nada de archivos de migración. Esta es la magia del desarrollo impulsado por esquemas.

Si quieres verificar, ejecuta: pnpm db:studio y verás una interfaz web en https://local.drizzle.studio con tu tabla message ahí sentada, lista para recibir datos.

Paso 4: El Sistema Nervioso (tRPC)

Esta es la parte que usualmente le vuela la cabeza a la gente. Con REST o GraphQL, tienes que definir endpoints, tipos y fetchers por separado. Con tRPC, tu función de backend es tu función de frontend.

Estamos creando un procedimiento que guarda el mensaje del usuario, toma el historial (el contexto es rey en IA), lo envía a Gemini y guarda la respuesta.

Crea 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;

Registra el router en 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;

Mira ese flujo. Es lineal, legible y completamente tipado. Si cambias el esquema de la base de datos, este código se pone rojo inmediatamente. Sin sorpresas en tiempo de ejecución.

¿Por qué el .reverse()? Consultamos los mensajes en orden descendente (los más nuevos primero) pero los LLMs esperan orden cronológico (los más antiguos primero). Es un pequeño detalle que evita conversaciones confusas.

Paso 5: La Interfaz

En apps/nextjs/src/app/chat/page.tsx, lo conectamos todo. Como estamos usando tRPC, obtenemos React Query gratis. useQuery maneja la obtención de datos, el caché y los estados de carga sin que escribamos un solo useEffect para hacer fetch de datos.

(He incluido un useEffect solo para hacer scroll al fondo —porque la 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();

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

No olvides la página de inicio. Actualiza 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>
  );
}

Ejecuta pnpm dev y visita http://localhost:3000. Haz clic en "Start Chatting" y tendrás un chatbot de IA funcionando.

La magia de tRPC: ¿Notas cómo nunca escribimos una petición a la API? Sin llamadas fetch(), sin cadenas de URL, sin manejo manual de errores. TypeScript sabe lo que sendMsg.mutate() espera. Si cambias el esquema de entrada del backend, tu frontend lanzará un error de compilación. Este es el futuro.

Paso 6: Inyectando Alma (La prueba de "Vibra")

Un asistente genérico es aburrido. Un asistente genérico termina borrado. La belleza de los LLMs es que son excelentes actores de rol.

He descubierto que darle a tu bot una opinión fuerte lo hace 10 veces más atractivo. No le indiques simplemente "Eres útil". Indícale una personalidad.

Modifiquemos el backend para aceptar una persona. Actualiza 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
};

Actualiza el frontend para pasar la selección de personaje:

// 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>

Ahora no solo has construido un chatbot; has construido una plataforma de interacción de personajes. Eso es un producto.

Los Detalles Técnicos Que Realmente Te Importan

¿Por qué no usar simplemente Prisma?

Prisma es genial, pero Drizzle es más rápido. Estamos hablando de 2-3 veces más rendimiento en consultas. Cuando eres un fundador en solitario, cada milisegundo se acumula. Además, la sintaxis similar a SQL de Drizzle significa menos carga mental.

¿Qué hay de las respuestas en streaming?

El SDK de IA de Vercel soporta streaming desde el primer momento. Reemplaza generateText con streamText y usa el hook useChat en el frontend. Me lo salté aquí porque para un tutorial, petición/respuesta es más simple. ¿Pero en producción? Haz streaming de todo. Los usuarios perciben el streaming como "más rápido" incluso cuando el tiempo total es el mismo.

Gestión de la ventana de contexto

Ahora mismo estamos tomando los últimos 10 mensajes. Eso funciona hasta que deja de funcionar. Si estás construyendo un producto serio, implementa un contador de tokens y ajusta dinámicamente el historial. El SDK de IA tiene utilidades para esto.

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

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

Pooling de conexiones de base de datos

Postgres local está bien para desarrollo. Para producción, usa Vercel Postgres o Supabase. Ellos manejan el pooling de conexiones automáticamente. Serverless + conexiones a base de datos es una trampa —no lo gestiones tú mismo.

Conclusiones Prácticas

Si estás leyendo esto y sientes la picazón por programar, aquí está mi consejo:

  1. No empieces desde cero. El código repetitivo (boilerplate) es el enemigo del impulso. Usa T3 Turbo o andamiajes similares.
  2. El tipado seguro es velocidad. Se siente más lento durante la primera hora, y más rápido durante los siguientes diez años. Atrapa los errores que usualmente ocurren durante una demo.
  3. El contexto es clave. Un chatbot sin historial es solo una barra de búsqueda elegante. Siempre pasa los últimos mensajes al LLM.
  4. Personalidad > características. Un bot que suena como Tony Stark obtendrá más interacción que un bot genérico con 10 funciones extra.

La Realidad Desordenada

Construir esto no fue todo un camino de rosas. Inicialmente arruiné la cadena de conexión de la base de datos y pasé 20 minutos preguntándome por qué Drizzle me estaba gritando. También alcancé un límite de velocidad en Gemini porque estaba enviando demasiado historial al principio (lección: siempre empieza con .limit(5) y escala).

¿La animación de carga? Eso me tomó tres intentos para que saliera bien porque las animaciones CSS siguen siendo, de alguna manera, magia negra en 2024.

Pero aquí está la cosa: como estaba usando un stack robusto, esos eran problemas de lógica, no problemas estructurales. Los cimientos se mantuvieron firmes. Nunca tuve que refactorizar toda la API porque elegí la abstracción equivocada.

Lánzalo

Estamos viviendo en una edad de oro para construir. Las herramientas son poderosas, la IA es inteligente y la barrera de entrada nunca ha sido tan baja.

Tienes el código ahora. Tienes el stack. Entiendes los compromisos.

Ve a construir algo que no debería existir, y lánzalo antes de la cena.

Tiempo total de construcción: ~2 horas Líneas de código real escritas: ~200 Bugs encontrados en producción: 0 (hasta ahora)

El stack T3 + Gemini no es solo rápido —es aburrido en el mejor sentido. Sin sorpresas. Nada de "funciona en mi máquina". Solo construir.

Feliz código.


Recursos:

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

Excerpt: La complejidad mata a las startups. Descubre cómo el stack T3 Turbo + Gemini AI permite a los fundadores construir y lanzar productos de IA robustos en horas, no semanas. Una guía práctica para escapar del infierno de la configuración y empezar a enviar código.

Compartir esto

Feng Liu

Feng Liu

shenjian8628@gmail.com