Parte 1: Come creare un Chatbot con T3 Turbo e Gemini
Molti founder restano bloccati nell'"inferno del setup". Io ho costruito un chatbot AI completamente type-safe in un solo pomeriggio. Ecco lo stack esatto—Next.js, tRPC e Gemini—e il codice per replicarlo.

Title: Costruire un Chatbot AI in 2 Ore: Lo Stack T3 Turbo + Gemini
Content: La complessità è il killer silenzioso delle startup in fase iniziale. Parti con un'idea semplice—"Voglio un chatbot che parli come Tony Stark"—e tre settimane dopo sei ancora lì a configurare Webpack, a combattere con i container Docker o a fare il debug di un flusso di autenticazione che nessuno ha ancora usato.
È una trappola in cui ho visto cadere ingegneri di grande talento, volta dopo volta. Amiamo i nostri strumenti. Amiamo ottimizzare. Ma nel gioco delle startup, rilasciare (shipping) è l'unica metrica che conta.
Se non hai dato un'occhiata al moderno ecosistema TypeScript ultimamente, potresti rimanere sorpreso. I giorni in cui si cucivano insieme API disparate pregando che reggessero sono ormai alle spalle. Siamo entrati nell'era del "Vibe Coder"—dove la distanza tra un'idea e un prodotto distribuito si misura in ore, non in sprint.
Oggi ti guiderò attraverso uno stack che sembra quasi un cheat code: Create T3 Turbo combinato con Google Gemini AI. È type-safe dal database al frontend, è incredibilmente veloce e, onestamente, riporta la gioia nel fare coding.
Perché questo Stack è importante
Potresti pensare: "Feng, perché un altro stack? Non posso usare semplicemente Python e Streamlit?"
Certo, per un prototipo. Ma se stai costruendo un prodotto—qualcosa che deve scalare, gestire utenti e mantenere lo stato—hai bisogno di una vera architettura. Il problema è che "vera architettura" di solito significa "settimane di boilerplate".
Il T3 Stack (Next.js, tRPC, Tailwind) ribalta questo copione. Ti dà la robustezza di un'applicazione full-stack con la velocità di sviluppo di uno script. Quando aggiungi Drizzle ORM (leggero, simile a SQL) e Google Gemini (veloce, piano gratuito generoso), hai un toolkit che permette a un founder solitario di superare in manovra un team di dieci persone.
Costruiamo qualcosa di reale.
Passo 1: Il Setup con un solo comando
Dimentica la configurazione manuale di ESLint e Prettier. Useremo create-t3-turbo. Questo imposta una struttura monorepo che è perfetta perché separa la logica API dal frontend Next.js, rendendoti a prova di futuro per quando inevitabilmente lancerai un'app mobile React Native più avanti.
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
Quando richiesto, ho selezionato Next.js, tRPC e PostgreSQL. Ho saltato l'Auth per ora perché, ripeto, stiamo ottimizzando per la spedizione, non per la perfezione. Puoi aggiungere NextAuth più tardi in dieci minuti.
La struttura monorepo che ottieni:
my-chatbot/
├── apps/nextjs/ # La tua web app
├── packages/
│ ├── api/ # Router tRPC (logica condivisa)
│ ├── db/ # Schema Database + Drizzle
│ └── ui/ # Componenti condivisi
Questa separazione significa che la tua logica API può essere riutilizzata tra web, mobile o persino app CLI. Ho visto team sprecare mesi a fare refactoring perché avevano iniziato con tutto in un'unica cartella.
Passo 2: Il Cervello (Gemini)
OpenAI è fantastica, ma hai provato Gemini Flash? È incredibilmente veloce e il prezzo è aggressivo. Per un'interfaccia di chat dove la latenza uccide il vibe, la velocità è una feature.
Perché Gemini Flash rispetto a GPT-3.5/4?
- Velocità: ~800ms vs 2-3s di tempo di risposta
- Costo: 60 volte più economico di GPT-4
- Contesto: Finestra di contesto da 1M di token (sì, un milione)
Abbiamo bisogno dell'AI SDK per standardizzare il dialogo con gli LLM.
cd packages/api
pnpm add ai @ai-sdk/google
Imposta il tuo .env nella root del progetto. Non complicarti la vita con il database in locale; un'istanza Postgres locale va benissimo.
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="la_tua_chiave_qui"
Pro tip: Ottieni la tua chiave API Gemini da https://aistudio.google.com/app/apikey. Il piano gratuito è assurdamente generoso—60 richieste al minuto. Raggiungerai il Product-Market Fit prima di toccare i rate limits.
Passo 3: Definire la Realtà (Lo Schema)
Qui è dove Drizzle brilla. Ai vecchi tempi, scrivevi le migrazioni a mano. Ora, definisci il tuo schema in TypeScript e il database obbedisce.
In packages/db/src/schema.ts, definiamo cos'è un "Messaggio". Noti come usiamo drizzle-zod? Questo crea automaticamente schemi di validazione per la nostra API. Questo è il principio "Don't Repeat Yourself" in azione.
import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
// Tabella messaggi per il chatbot
export const Message = pgTable("message", (t) => ({
id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
role: t.varchar({ length: 20 }).notNull(), // 'user' o 'assistant'
content: t.text().notNull(),
createdAt: t.timestamp().defaultNow().notNull(),
}));
// Schema Zod generato automaticamente dalla definizione della tabella
export const CreateMessageSchema = createInsertSchema(Message, {
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });
Fai il push: pnpm db:push. Fatto. Il tuo database ora esiste.
Cosa è appena successo? Drizzle ha guardato la tua definizione TypeScript e ha creato la tabella. Nessun SQL scritto. Nessun file di migrazione. Questa è la magia dello sviluppo guidato dallo schema.
Se vuoi verificare, esegui: pnpm db:studio e vedrai una UI web su https://local.drizzle.studio con la tua tabella message lì, pronta a ricevere dati.
Passo 4: Il Sistema Nervoso (tRPC)
Questa è la parte che di solito lascia le persone a bocca aperta. Con REST o GraphQL, devi definire endpoint, tipi e fetcher separatamente. Con tRPC, la tua funzione backend è la tua funzione frontend.
Stiamo creando una procedura che salva il messaggio dell'utente, recupera la cronologia (il contesto è re nell'AI), lo invia a Gemini e salva la risposta.
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. Salva il Messaggio Utente
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
// 2. Ottieni il Contesto (Ultimi 10 messaggi)
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
// 3. Chiedi a 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. Salva la Risposta 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;
Registra il router in 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;
Guarda quel flusso. È lineare, leggibile e completamente tipizzato. Se cambi lo schema del database, questo codice diventa rosso immediatamente. Nessuna sorpresa a runtime.
Perché il .reverse()? Interroghiamo i messaggi in ordine decrescente (i più nuovi prima) ma gli LLM si aspettano un ordine cronologico (i più vecchi prima). È un piccolo dettaglio che previene conversazioni confuse.

Passo 5: L'Interfaccia
In apps/nextjs/src/app/chat/page.tsx, colleghiamo il tutto. Poiché stiamo usando tRPC, otteniamo React Query gratis. useQuery gestisce il fetching, la cache e gli stati di caricamento senza che dobbiamo scrivere un singolo useEffect per il recupero dati.
(Ho incluso un useEffect solo per lo scorrimento verso il basso—perché la UX conta).
"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();
// Fetching automatico dei dati con caching
const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());
// Mutation con aggiornamenti ottimistici
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 all'ultimo messaggio
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>
{/* Messaggi */}
<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="Scrivi il tuo messaggio..."
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"
>
Invia
</button>
</div>
</form>
</div>
);
}
Non dimenticare la homepage. Aggiorna 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"
>
Inizia a Chattare
</Link>
</div>
</main>
);
}
Esegui pnpm dev e visita http://localhost:3000. Clicca su "Inizia a Chattare" e avrai un chatbot AI funzionante.
La magia di tRPC: Nota come non abbiamo mai scritto una fetch API? Nessuna chiamata fetch(), nessuna stringa URL, nessuna gestione manuale degli errori. TypeScript sa cosa si aspetta sendMsg.mutate(). Se cambi lo schema di input del backend, il tuo frontend lancerà un errore di compilazione. Questo è il futuro.
Passo 6: Iniettare l'Anima (Il "Vibe" Check)
Un assistente generico è noioso. Un assistente generico viene cancellato. La bellezza degli LLM è che sono eccellenti nel gioco di ruolo.
Ho scoperto che dare al tuo bot un'opinione forte lo rende 10 volte più coinvolgente. Non limitarti a promptare "Sei utile". Prompta per una personalità.
Modifichiamo il backend per accettare una persona. Aggiorna 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 }) => {
// Scegli la personalità
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 dinamico
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();
}),
// ... il resto rimane uguale
};
Aggiorna il frontend per passare la selezione del personaggio:
// Nel componente ChatPage, aggiungi lo stato per il personaggio
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");
// Aggiorna la chiamata mutation
sendMsg.mutate({ content: input.trim(), character });
// Aggiungi un dropdown prima dell'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>
Ora non hai solo costruito un chatbot; hai costruito una piattaforma di interazione con personaggi. Questo è un prodotto.
I Dettagli Tecnici che ti interessano davvero
Perché non usare semplicemente Prisma?
Prisma è ottimo, ma Drizzle è più veloce. Parliamo di prestazioni delle query 2-3 volte superiori. Quando sei un founder solitario, ogni millisecondo si accumula. Inoltre, la sintassi simil-SQL di Drizzle significa meno carico mentale.
E per le risposte in streaming?
Il Vercel AI SDK supporta lo streaming nativamente. Sostituisci generateText con streamText e usa l'hook useChat sul frontend. L'ho saltato qui perché per un tutorial, richiesta/risposta è più semplice. Ma in produzione? Fai streaming di tutto. Gli utenti percepiscono lo streaming come "più veloce" anche quando il tempo totale è lo stesso.
Gestione della finestra di contesto
Al momento stiamo prendendo gli ultimi 10 messaggi. Funziona finché non funziona più. Se stai costruendo un prodotto serio, implementa un contatore di token e adatta dinamicamente la cronologia. L'AI SDK ha utility per questo.
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Controlla i costi
// ...
});
Pooling delle connessioni al database
Postgres locale va bene per lo sviluppo. Per la produzione, usa Vercel Postgres o Supabase. Gestiscono il pooling delle connessioni automaticamente. Serverless + connessioni al database è una trappola—non gestirlo da solo.
Takeaway Pratici
Se stai leggendo questo e senti il prurito di programmare, ecco il mio consiglio:
- Non partire da zero. Il boilerplate è il nemico del momentum. Usa T3 Turbo o impalcature simili.
- La type safety è velocità. Sembra più lenta per la prima ora, e più veloce per i prossimi dieci anni. Cattura i bug che di solito accadono durante una demo.
- Il contesto è la chiave. Un chatbot senza cronologia è solo una barra di ricerca sofisticata. Passa sempre gli ultimi messaggi all'LLM.
- Personalità > funzionalità. Un bot che suona come Tony Stark otterrà più engagement di un bot generico con 10 funzionalità extra.
La Realtà Disordinata
Costruire questo non è stato tutto rose e fiori. Inizialmente ho sbagliato la stringa di connessione al database e ho passato 20 minuti a chiedermi perché Drizzle mi stesse urlando contro. Ho anche colpito un rate limit su Gemini perché stavo inviando troppa cronologia all'inizio (lezione: inizia sempre con .limit(5) e scala).
L'animazione di caricamento? Mi ci sono voluti tre tentativi per farla giusta perché le animazioni CSS sono ancora, in qualche modo, magia nera nel 2024.
Ma ecco il punto: poiché stavo usando uno stack robusto, quelli erano problemi logici, non problemi strutturali. Le fondamenta hanno retto. Non ho mai dovuto rifattorizzare l'intera API perché avevo scelto l'astrazione sbagliata.
Rilascia (Ship It)
Stiamo vivendo in un'età dell'oro per costruire. Gli strumenti sono potenti, l'AI è intelligente e la barriera all'ingresso non è mai stata così bassa.
Hai il codice ora. Hai lo stack. Capisci i compromessi.
Vai a costruire qualcosa che non dovrebbe esistere, e rilascialo prima di cena.
Tempo totale di costruzione: ~2 ore Righe di codice vero scritte: ~200 Bug incontrati in produzione: 0 (finora)
Lo stack T3 + Gemini non è solo veloce—è noioso nel modo migliore possibile. Nessuna sorpresa. Nessun "sulla mia macchina funziona". Solo costruire.
Buon coding.
Risorse:
Codice completo: github.com/giftedunicorn/my-chatbot
Excerpt: La complessità uccide le startup. Scopri come costruire un chatbot AI completo in meno di 2 ore usando lo stack T3 Turbo, Drizzle e Google Gemini. Type-safe, veloce e pronto per la produzione.
Condividi questo

Feng Liu
shenjian8628@gmail.com