Deel 1: Hoe bouw je een Chatbot met T3 Turbo & Gemini
De meeste founders blijven hangen in 'setup hell'. Ik bouwde in één middag een volledig type-safe AI-chatbot. Hier is de exacte stackâNext.js, tRPC en Geminiâinclusief de code om het zelf te bouwen.

Title: De T3 Stack + Gemini: Hoe je in één weekend een AI-app bouwt (en shipt)
Excerpt: Complexiteit is de stille moordenaar van startups. In deze gids laat ik je zien hoe je de T3 Stack (Next.js, tRPC, Tailwind) combineert met Google Gemini om in recordtijd een productie-klare AI-app te bouwen. Geen boilerplate-hel, gewoon bouwen.
Complexiteit is de stille moordenaar van early-stage startups. Je begint met een simpel ideeâ"Ik wil een chatbot die praat als Tony Stark"âen drie weken later ben je nog steeds Webpack aan het configureren, vecht je met Docker containers, of ben je een authenticatie-flow aan het debuggen die nog door niemand is gebruikt.
Het is een valkuil waar ik briljante engineers keer op keer in heb zien trappen. We houden van onze tools. We houden van optimaliseren. Maar in de startup-wereld is shippen de enige metric die telt.
Als je recentelijk niet naar het moderne TypeScript-ecosysteem hebt gekeken, zul je verrast zijn. De tijd dat we losse API's aan elkaar knoopten en hoopten dat het bleef werken, ligt grotendeels achter ons. We zijn het tijdperk van de "Vibe Coder" binnengetredenâwaarin de afstand tussen een idee en een gedeployd product wordt gemeten in uren, niet in sprints.
Vandaag neem ik je mee door een stack die voelt als een cheat code: Create T3 Turbo gecombineerd met Google's Gemini AI. Het is type-safe van de database tot de frontend, het is belachelijk snel, en eerlijk gezegd: het brengt het plezier terug in het coderen.
Waarom deze stack belangrijk is
Je denkt misschien: "Feng, waarom weer een nieuwe stack? Kan ik niet gewoon Python en Streamlit gebruiken?"
Tuurlijk, voor een prototype. Maar als je een product bouwtâiets dat moet schalen, gebruikers moet aan kunnen en state moet beherenâheb je een echte architectuur nodig. Het probleem is dat "echte architectuur" meestal "weken aan boilerplate" betekent.
De T3 Stack (Next.js, tRPC, Tailwind) draait dit script om. Het geeft je de robuustheid van een full-stack applicatie met de ontwikkelsnelheid van een script. Wanneer je Drizzle ORM (lichtgewicht, SQL-achtig) en Google Gemini (snel, gulle gratis tier) toevoegt, heb je een toolkit waarmee een solo founder een team van tien man eruit kan coderen.
Laten we iets echts gaan bouwen.
Stap 1: De setup met één commando
Vergeet het handmatig configureren van ESLint en Prettier. We gebruiken create-t3-turbo. Dit zet een monorepo-structuur op, wat perfect is omdat het je API-logica scheidt van je Next.js frontend. Hiermee ben je direct toekomstbestendig voor wanneer je later onvermijdelijk die React Native mobiele app lanceert.
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
Toen erom gevraagd werd, selecteerde ik Next.js, tRPC, en PostgreSQL. Ik heb Auth voor nu overgeslagen, want nogmaals: we optimaliseren voor shippen, niet voor perfectie. NextAuth kun je later in tien minuten toevoegen.
De monorepo structuur die je krijgt:
my-chatbot/
âââ apps/nextjs/ # Je web app
âââ packages/
â âââ api/ # tRPC routers (gedeelde logica)
â âââ db/ # Database schema + Drizzle
â âââ ui/ # Gedeelde componenten
Deze scheiding betekent dat je API-logica hergebruikt kan worden voor web, mobiel of zelfs CLI-apps. Ik heb teams maanden zien verspillen aan refactoring omdat ze begonnen met alles in één map.
Stap 2: Het Brein (Gemini)
OpenAI is geweldig, maar heb je Gemini Flash al geprobeerd? Het is ongelooflijk snel en de prijsstelling is agressief. Voor een chat-interface waar latency de vibe verpest, is snelheid een feature.
Waarom Gemini Flash boven GPT-3.5/4?
- Snelheid: ~800ms vs 2-3s responstijd
- Kosten: 60x goedkoper dan GPT-4
- Context: 1M token context window (ja, één miljoen)
We hebben de AI SDK nodig om het praten met LLM's te standaardiseren.
cd packages/api
pnpm add ai @ai-sdk/google
Stel je .env in de root van het project in. Denk lokaal niet te moeilijk over de database; een lokale Postgres instantie is prima.
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="jouw_key_hier"
Pro tip: Haal je Gemini API key op via https://aistudio.google.com/app/apikey. De gratis tier is absurd gulâ60 requests per minuut. Je bereikt Product-Market Fit voordat je tegen de rate limits aanloopt.
Stap 3: Definieer de Realiteit (Het Schema)
Dit is waar Drizzle schittert. Vroeger schreef je migraties met de hand. Nu definieer je je schema in TypeScript, en de database gehoorzaamt.
In packages/db/src/schema.ts definiëren we wat een "Message" is. Zie je hoe we drizzle-zod gebruiken? Dit maakt automatisch validatie-schema's aan voor onze API. Dit is het "Don't Repeat Yourself"-principe in actie.
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 });
Push het: pnpm db:push. Klaar. Je database bestaat nu.
Wat is er net gebeurd? Drizzle keek naar je TypeScript definitie en maakte de tabel aan. Geen SQL geschreven. Geen migratiebestanden. Dit is de magie van schema-driven development.
Als je het wilt verifiëren, draai dan: pnpm db:studio en je ziet een web UI op https://local.drizzle.studio met je message tabel, klaar om data te ontvangen.
Stap 4: Het Zenuwstelsel (tRPC)
Dit is het gedeelte wat mensen vaak versteld doet staan. Met REST of GraphQL moet je endpoints, types en fetchers apart definiëren. Met tRPC is je backend functie je frontend functie.
We maken een procedure die het bericht van de gebruiker opslaat, de geschiedenis ophaalt (context is king in AI), het naar Gemini stuurt, en het antwoord opslaat.
Maak packages/api/src/router/chat.ts aan:
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;
Registreer de 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;
Kijk naar die flow. Het is lineair, leesbaar en volledig getypeerd. Als je het database schema verandert, kleurt deze code direct rood. Geen verrassingen tijdens runtime.
Waarom de .reverse()? We vragen berichten op in aflopende volgorde (nieuwste eerst), maar LLM's verwachten chronologische volgorde (oudste eerst). Het is een klein detail dat verwarrende gesprekken voorkomt.

Stap 5: De Interface
In apps/nextjs/src/app/chat/page.tsx koppelen we alles aan elkaar. Omdat we tRPC gebruiken, krijgen we React Query er gratis bij. useQuery handelt het ophalen, cachen en de laad-statussen af zonder dat we ook maar één useEffect hoeven te schrijven voor data fetching.
(Ik heb alleen een useEffect toegevoegd om naar beneden te scrollenâwant UX is belangrijk).
"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>
);
}
Vergeet de homepage niet. Update 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>
);
}
Draai pnpm dev en ga naar http://localhost:3000. Klik op "Start Chatting" en je hebt een werkende AI chatbot.
De magie van tRPC: Merk je op dat we nooit een API fetch hebben geschreven? Geen fetch() calls, geen URL strings, geen handmatige error handling. TypeScript weet wat sendMsg.mutate() verwacht. Als je het backend input schema verandert, geeft je frontend een compile error. Dit is de toekomst.
Stap 6: Ziel toevoegen (De "Vibe" Check)
Een generieke assistent is saai. Een generieke assistent wordt verwijderd. Het mooie van LLM's is dat ze uitstekende rollenspellen kunnen spelen.
Ik heb gemerkt dat als je je bot een sterke mening geeft, hij 10x boeiender wordt. Prompt niet alleen "Je bent behulpzaam." Prompt voor een persoonlijkheid.
Laten we de backend aanpassen om een persona te accepteren. Update 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
};
Update de frontend om de karakter-selectie door te geven:
// 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 heb je niet zomaar een chatbot gebouwd; je hebt een karakter-interactie platform gebouwd. Dat is een product.
De technische details waar je écht om geeft
Waarom niet gewoon Prisma gebruiken?
Prisma is geweldig, maar Drizzle is sneller. We hebben het over 2-3x query performance. Als je een solo founder bent, telt elke milliseconde dubbel. Plus, Drizzle's SQL-achtige syntax betekent minder mentale overhead.
Hoe zit het met streaming responses?
De Vercel AI SDK ondersteunt streaming out-of-the-box. Vervang generateText door streamText en gebruik de useChat hook in de frontend. Ik heb het hier overgeslagen omdat request/response voor een tutorial simpeler is. Maar in productie? Stream alles. Gebruikers ervaren streaming als "sneller", zelfs als de totale tijd hetzelfde is.
Context window management
Op dit moment pakken we de laatste 10 berichten. Dat werkt, totdat het niet meer werkt. Als je een serieus product bouwt, implementeer dan een token counter en pas de geschiedenis dynamisch aan. De AI SDK heeft hier utilities voor.
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Control costs
// ...
});
Database connection pooling
Lokale Postgres is prima voor dev. Gebruik voor productie Vercel Postgres of Supabase. Zij handelen connection pooling automatisch af. Serverless + database connecties is een valkuilâbeheer dit niet zelf.
Praktische Takeaways
Als je dit leest en je vingers jeuken om te coderen, is hier mijn advies:
- Begin niet vanaf nul. Boilerplate is de vijand van momentum. Gebruik T3 Turbo of vergelijkbare scaffolding.
- Type safety is snelheid. Het voelt langzamer in het eerste uur, en sneller voor de komende tien jaar. Het vangt de bugs die normaal gesproken tijdens een demo opduiken.
- Context is key. Een chatbot zonder geschiedenis is gewoon een luxe zoekbalk. Geef altijd de laatste paar berichten mee aan de LLM.
- Persoonlijkheid > features. Een bot die klinkt als Tony Stark krijgt meer engagement dan een generieke bot met 10 extra features.
De rommelige realiteit
Het bouwen hiervan ging niet zonder slag of stoot. Ik verprutste in het begin de database connection string en vroeg me 20 minuten lang af waarom Drizzle tegen me aan het schreeuwen was. Ik liep ook tegen een rate limit aan bij Gemini omdat ik in eerste instantie te veel geschiedenis meestuurde (les: begin altijd met .limit(5) en schaal dan op).
De laad-animatie? Daar had ik drie pogingen voor nodig om het goed te krijgen, want CSS animaties zijn in 2024 op de een of andere manier nog steeds zwarte magie.
Maar hier is het ding: omdat ik een robuuste stack gebruikte, waren dit logische problemen, geen structurele problemen. Het fundament bleef staan. Ik hoefde nooit de hele API te refactoren omdat ik de verkeerde abstractie had gekozen.
Ship It
We leven in een gouden tijdperk voor bouwers. De tools zijn krachtig, de AI is slim, en de drempel om in te stappen is nog nooit zo laag geweest.
Je hebt de code nu. Je hebt de stack. Je begrijpt de afwegingen.
Ga iets bouwen dat eigenlijk niet zou moeten bestaan, en ship het voor het avondeten.
Totale bouwtijd: ~2 uur Regels code daadwerkelijk geschreven: ~200 Bugs tegengekomen in productie: 0 (tot nu toe)
De T3 stack + Gemini is niet alleen snelâhet is saai op de best mogelijke manier. Geen verrassingen. Geen "het werkt op mijn machine." Gewoon bouwen.
Happy coding.
Resources:
Volledige code: github.com/giftedunicorn/my-chatbot
Deel dit

Feng Liu
shenjian8628@gmail.com