Del 1: Så bygger du en chatbot med T3 Turbo & Gemini
De flesta grundare fastnar i "setup hell". Jag byggde precis en helt typsäker AI-chattbot på en eftermiddag. Här är den exakta stacken – Next.js, tRPC och Gemini – samt koden för att göra det själv.

Komplexitet är den tysta mördaren för tidiga startups. Du börjar med en enkel idé – "Jag vill ha en chatbot som pratar som Tony Stark" – och tre veckor senare sitter du fortfarande och konfigurerar Webpack, bråkar med Docker-containrar eller debuggar ett autentiseringsflöde som ingen ens har använt än.
Det är en fälla jag har sett briljanta ingenjörer falla i gång på gång. Vi älskar våra verktyg. Vi älskar att optimera. Men i startup-världen är att skeppa det enda mätvärdet som räknas.
Om du inte har kollat in det moderna TypeScript-ekosystemet på sistone kanske du blir förvånad. Dagarna då vi sydde ihop spretiga API:er och bad till gudarna att de skulle hålla ihop är i stort sett förbi. Vi har gått in i eran av "Vibe Coder" – där avståndet mellan en idé och en deployad produkt mäts i timmar, inte sprintar.
Idag ska jag guida dig genom en stack som känns som en cheat code: Create T3 Turbo kombinerat med Googles Gemini AI. Den är typsäker från databasen till frontend, den är löjligt snabb, och ärligt talat – den gör det kul att koda igen.
Varför den här stacken spelar roll
Du kanske tänker: "Feng, varför ännu en stack? Kan jag inte bara använda Python och Streamlit?"
Visst, för en prototyp. Men om du bygger en produkt – något som ska skala, hantera användare och bibehålla state – behöver du en riktig arkitektur. Problemet är att "riktig arkitektur" vanligtvis betyder "veckor av boilerplate-kod".
T3-stacken (Next.js, tRPC, Tailwind) vänder på steken. Den ger dig robustheten hos en fullstack-applikation med utvecklingshastigheten hos ett script. När du lägger till Drizzle ORM (lättviktigt, SQL-liknande) och Google Gemini (snabb, generös gratisnivå), har du en verktygslåda som låter en sologrundare utmanövrera ett team på tio personer.
Låt oss bygga något på riktigt.
Steg 1: Setup med ett kommando
Glöm att manuellt konfigurera ESLint och Prettier. Vi kommer att använda create-t3-turbo. Detta sätter upp en monorepo-struktur vilket är perfekt eftersom det separerar din API-logik från din Next.js-frontend, vilket framtidssäkrar dig för när du oundvikligen lanserar en React Native-mobilapp senare.
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
När jag fick frågan valde jag Next.js, tRPC och PostgreSQL. Jag hoppade över Auth för tillfället eftersom vi, återigen, optimerar för att skeppa, inte för perfektion. Du kan lägga till NextAuth senare på tio minuter.
Monorepo-strukturen du får:
my-chatbot/
├── apps/nextjs/ # Your web app
├── packages/
│ ├── api/ # tRPC routers (shared logic)
│ ├── db/ # Database schema + Drizzle
│ └── ui/ # Shared components
Denna separation innebär att din API-logik kan återanvändas över webb, mobil eller till och med CLI-appar. Jag har sett team slösa månader på refactoring bara för att de började med allt i en enda mapp.
Steg 2: Hjärnan (Gemini)
OpenAI är bra, men har du testat Gemini Flash? Det är otroligt snabbt och prissättningen är aggressiv. För ett chattgränssnitt där latens dödar vibben, är hastighet en feature.
Varför Gemini Flash över GPT-3.5/4?
- Hastighet: ~800ms vs 2-3s svarstid
- Kostnad: 60x billigare än GPT-4
- Kontext: 1M token kontextfönster (ja, en miljon)
Vi behöver AI SDK:n för att standardisera kommunikationen med LLM:er.
cd packages/api
pnpm add ai @ai-sdk/google
Ställ in din .env i projektets rot. Övertänk inte databasen lokalt; en lokal Postgres-instans duger fint.
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"
Proffstips: Hämta din Gemini API-nyckel från https://aistudio.google.com/app/apikey. Gratisnivån är absurt generös – 60 förfrågningar per minut. Du kommer att nå Product-Market Fit innan du slår i taket för rate limits.
Steg 3: Definiera verkligheten (Schemat)
Det är här Drizzle glänser. Förr i tiden skrev du migrationer för hand. Nu definierar du ditt schema i TypeScript, och databasen lyder.
I packages/db/src/schema.ts definierar vi vad ett "Message" är. Lägg märke till hur vi använder drizzle-zod? Detta skapar automatiskt valideringsscheman för vårt API. Detta är "Don't Repeat Yourself"-principen i praktiken.
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 });
Pusha det: pnpm db:push. Klart. Din databas existerar nu.
Vad hände precis? Drizzle tittade på din TypeScript-definition och skapade tabellen. Ingen SQL skriven. Inga migrationsfiler. Detta är magin med schemadriven utveckling.
Om du vill verifiera, kör: pnpm db:studio så ser du ett webbgränssnitt på https://local.drizzle.studio med din message-tabell redo att ta emot data.
Steg 4: Nervsystemet (tRPC)
Det här är delen som brukar få folk att tappa hakan. Med REST eller GraphQL måste du definiera endpoints, typer och fetchers separat. Med tRPC är din backend-funktion din frontend-funktion.
Vi skapar en procedur som sparar användarens meddelande, hämtar historik (kontext är kung inom AI), skickar det till Gemini och sparar svaret.
Skapa 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;
Registrera routern i 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;
Titta på det flödet. Det är linjärt, läsbart och helt typat. Om du ändrar databasschemat blir den här koden röd direkt. Inga överraskningar vid körning.
Varför .reverse()? Vi hämtar meddelanden i fallande ordning (nyast först) men LLM:er förväntar sig kronologisk ordning (äldst först). Det är en liten detalj som förhindrar förvirrande konversationer.

Steg 5: Gränssnittet
I apps/nextjs/src/app/chat/page.tsx kopplar vi ihop allt. Eftersom vi använder tRPC får vi React Query på köpet. useQuery hanterar hämtning, caching och laddningstillstånd utan att vi behöver skriva en enda useEffect för datahämtning.
(Jag har inkluderat en useEffect bara för att scrolla till botten – för UX spelar roll).
"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>
);
}
Glöm inte startsidan. Uppdatera 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>
);
}
Kör pnpm dev och besök http://localhost:3000. Klicka på "Start Chatting" och du har en fungerande AI-chatbot.
Magin med tRPC: Märkte du att vi aldrig skrev en API-fetch? Inga fetch()-anrop, inga URL-strängar, ingen manuell felhantering. TypeScript vet vad sendMsg.mutate() förväntar sig. Om du ändrar backend-inputschemat kommer din frontend att kasta ett kompileringsfel. Det här är framtiden.
Steg 6: Injicera själ (Vibe-checken)
En generisk assistent är tråkig. En generisk assistent blir raderad. Det fina med LLM:er är att de är utmärkta rollspelare.
Jag har upptäckt att om du ger din bot en stark åsikt blir den 10x mer engagerande. Prompta inte bara "You are helpful." Prompta för en personlighet.
Låt oss modifiera backend för att acceptera en persona. Uppdatera 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
};
Uppdatera frontend för att skicka med karaktärsvalet:
// 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>
Nu har du inte bara byggt en chatbot; du har byggt en plattform för karaktärsinteraktion. Det är en produkt.
De tekniska detaljerna du faktiskt bryr dig om
Varför inte bara använda Prisma?
Prisma är bra, men Drizzle är snabbare. Vi pratar 2-3x frågeprestanda. När du är en sologrundare räknas varje millisekund. Dessutom innebär Drizzles SQL-liknande syntax mindre mental overhead.
Hur är det med strömmande svar?
Vercel AI SDK stöder streaming direkt ur lådan. Byt ut generateText mot streamText och använd useChat-hooken på frontend. Jag hoppade över det här eftersom request/response är enklare för en tutorial. Men i produktion? Streama allt. Användare uppfattar streaming som "snabbare" även om den totala tiden är densamma.
Hantering av kontextfönster
Just nu hämtar vi de senaste 10 meddelandena. Det fungerar tills det inte gör det. Om du bygger en seriös produkt, implementera en token-räknare och justera historiken dynamiskt. AI SDK:n har verktyg för detta.
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Control costs
// ...
});
Databas-connection pooling
Lokal Postgres är okej för dev. För produktion, använd Vercel Postgres eller Supabase. De hanterar connection pooling automatiskt. Serverless + databasanslutningar är en fälla – hantera det inte själv.
Praktiska lärdomar
Om du läser detta och det kliar i fingrarna att börja koda, här är mitt råd:
- Börja inte från noll. Boilerplate är momentumets fiende. Använd T3 Turbo eller liknande byggnadsställningar.
- Typsäkerhet är hastighet. Det känns långsammare den första timmen, och snabbare de kommande tio åren. Det fångar buggarna som vanligtvis dyker upp under en demo.
- Kontext är nyckeln. En chatbot utan historik är bara ett fancy sökfält. Skicka alltid med de senaste meddelandena till LLM:en.
- Personlighet > funktioner. En bot som låter som Tony Stark kommer att få mer engagemang än en generisk bot med 10 extra funktioner.
Den stökiga verkligheten
Att bygga detta var inte en dans på rosor. Jag rörde till det med databasens connection string i början och spenderade 20 minuter med att undra varför Drizzle skrek på mig. Jag slog också i en rate limit på Gemini eftersom jag skickade för mycket historik i början (läxa: börja alltid med .limit(5) och skala upp).
Laddningsanimationen? Den tog mig tre försök att få till eftersom CSS-animationer fortfarande, på något sätt, är svart magi år 2024.
Men här är grejen: eftersom jag använde en robust stack var dessa logiska problem, inte strukturella problem. Grunden höll. Jag behövde aldrig refactorera hela API:et för att jag valde fel abstraktion.
Skeppa det
Vi lever i en guldålder för byggande. Verktygen är kraftfulla, AI:n är smart, och tröskeln för att komma igång har aldrig varit lägre.
Du har koden nu. Du har stacken. Du förstår avvägningarna.
Gå och bygg något som inte borde finnas, och skeppa det innan middagen.
Total byggtid: ~2 timmar Rader av faktisk kod skriven: ~200 Buggar påträffade i produktion: 0 (än så länge)
T3-stacken + Gemini är inte bara snabb – den är tråkig på bästa möjliga sätt. Inga överraskningar. Inget "funkar på min maskin." Bara byggande.
Happy coding.
Resurser:
Fullständig kod: github.com/giftedunicorn/my-chatbot
Dela detta

Feng Liu
shenjian8628@gmail.com