Partie 1 : Comment créer un Chatbot avec T3 Turbo & Gemini
Trop de fondateurs s'enlisent dans l'enfer de la configuration. Je viens de crĂ©er un chatbot IA entiĂšrement type-safe en une seule aprĂšs-midi. Voici ma stack exacte â Next.js, tRPC et Gemini â ainsi que le code pour le reproduire vous-mĂȘme.

La complexité : le tueur silencieux des startups early-stage
La complexitĂ© est le tueur silencieux des startups en phase d'amorçage. Vous partez d'une idĂ©e simple â « Je veux un chatbot qui parle comme Tony Stark » â et trois semaines plus tard, vous ĂȘtes encore en train de configurer Webpack, de vous battre avec des conteneurs Docker ou de dĂ©bugger un flux d'authentification que personne n'a encore utilisĂ©.
C'est un piÚge dans lequel j'ai vu tomber des ingénieurs brillants, encore et encore. Nous aimons nos outils. Nous aimons optimiser. Mais dans le jeu des startups, shipper est la seule métrique qui compte.
Si vous n'avez pas jetĂ© un Ćil Ă l'Ă©cosystĂšme TypeScript moderne rĂ©cemment, vous pourriez ĂȘtre surpris. L'Ă©poque oĂč l'on bricolait des API disparates en priant pour que le tout tienne debout est largement derriĂšre nous. Nous sommes entrĂ©s dans l'Ăšre du "Vibe Coder" â oĂč la distance entre une idĂ©e et un produit dĂ©ployĂ© se mesure en heures, et non plus en sprints.
Aujourd'hui, je vais vous prĂ©senter une stack qui ressemble Ă un cheat code : Create T3 Turbo combinĂ© Ă Google Gemini AI. C'est type-safe de la base de donnĂ©es jusqu'au frontend, c'est ridiculerment rapide et, honnĂȘtement, ça ramĂšne la joie dans le code.
Pourquoi cette stack est importante
Vous vous dites peut-ĂȘtre : « Feng, pourquoi encore une nouvelle stack ? Je ne peux pas juste utiliser Python et Streamlit ? »
Bien sĂ»r, pour un prototype. Mais si vous construisez un produit â quelque chose qui doit passer Ă l'Ă©chelle, gĂ©rer des utilisateurs et maintenir un Ă©tat â vous avez besoin d'une vĂ©ritable architecture. Le problĂšme, c'est que « vĂ©ritable architecture » signifie gĂ©nĂ©ralement « des semaines de boilerplate ».
La T3 Stack (Next.js, tRPC, Tailwind) change la donne. Elle vous offre la robustesse d'une application full-stack avec la vitesse de développement d'un script. Quand vous y ajoutez Drizzle ORM (léger, proche du SQL) et Google Gemini (rapide, avec une offre gratuite généreuse), vous obtenez une boßte à outils qui permet à un fondateur solo de déjouer une équipe de dix personnes.
Construisons quelque chose de concret.
Ătape 1 : L'installation en une commande
Oubliez la configuration manuelle d'ESLint et Prettier. Nous allons utiliser create-t3-turbo. Cela met en place une structure monorepo, ce qui est parfait car cela sĂ©pare votre logique API de votre frontend Next.js, vous prĂ©parant pour le jour oĂč vous lancerez inĂ©vitablement une application mobile React Native.
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
Lorsqu'on me l'a demandé, j'ai sélectionné Next.js, tRPC et PostgreSQL. J'ai passé l'Auth pour le moment car, encore une fois, nous optimisons pour l'expédition, pas la perfection. Vous pourrez ajouter NextAuth plus tard en dix minutes.
La structure monorepo que vous obtenez :
my-chatbot/
âââ apps/nextjs/ # Votre application web
âââ packages/
â âââ api/ # Routeurs tRPC (logique partagĂ©e)
â âââ db/ # SchĂ©ma de base de donnĂ©es + Drizzle
â âââ ui/ # Composants partagĂ©s
Cette sĂ©paration signifie que votre logique API peut ĂȘtre rĂ©utilisĂ©e sur le web, le mobile ou mĂȘme des applications CLI. J'ai vu des Ă©quipes perdre des mois Ă refactoriser parce qu'elles avaient tout commencĂ© dans un seul dossier.
Ătape 2 : Le Cerveau (Gemini)
OpenAI est gĂ©nial, mais avez-vous essayĂ© Gemini Flash ? C'est incroyablement rapide et la tarification est agressive. Pour une interface de chat oĂč la latence tue la "vibe", la vitesse est une fonctionnalitĂ© Ă part entiĂšre.
Pourquoi Gemini Flash plutĂŽt que GPT-3.5/4 ?
- Vitesse : ~800ms contre 2-3s de temps de réponse
- Coût : 60x moins cher que GPT-4
- Contexte : FenĂȘtre de contexte de 1M de tokens (oui, un million)
Nous avons besoin du AI SDK pour standardiser la communication avec les LLM.
cd packages/api
pnpm add ai @ai-sdk/google
Configurez votre .env Ă la racine du projet. Ne vous prenez pas la tĂȘte avec la base de donnĂ©es en local ; une instance Postgres locale suffit.
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"
Pro tip : RĂ©cupĂ©rez votre clĂ© API Gemini sur https://aistudio.google.com/app/apikey. L'offre gratuite est absurdement gĂ©nĂ©reuse â 60 requĂȘtes par minute. Vous atteindrez le Product-Market Fit avant d'atteindre les limites de taux.
Ătape 3 : DĂ©finir la RĂ©alitĂ© (Le SchĂ©ma)
C'est là que Drizzle brille. à l'époque, vous écriviez les migrations à la main. Maintenant, vous définissez votre schéma en TypeScript, et la base de données obéit.
Dans packages/db/src/schema.ts, nous définissons ce qu'est un "Message". Remarquez comment nous utilisons drizzle-zod ? Cela crée automatiquement des schémas de validation pour notre API. C'est le principe "Don't Repeat Yourself" (DRY) en action.
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 });
Poussez le tout : pnpm db:push. Terminé. Votre base de données existe maintenant.
Que vient-il de se passer ? Drizzle a regardé votre définition TypeScript et a créé la table. Pas de SQL écrit. Pas de fichiers de migration. C'est la magie du développement piloté par le schéma.
Si vous voulez vĂ©rifier, lancez : pnpm db:studio et vous verrez une interface web sur https://local.drizzle.studio avec votre table message prĂȘte Ă recevoir des donnĂ©es.
Ătape 4 : Le SystĂšme Nerveux (tRPC)
C'est la partie qui époustoufle souvent les gens. Avec REST ou GraphQL, vous devez définir les endpoints, les types et les fetchers séparément. Avec tRPC, votre fonction backend est votre fonction frontend.
Nous créons une procédure qui sauvegarde le message de l'utilisateur, récupÚre l'historique (le contexte est roi dans l'IA), l'envoie à Gemini et sauvegarde la réponse.
Créez 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;
Enregistrez le routeur dans 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;
Regardez ce flux. C'est linéaire, lisible et entiÚrement typé. Si vous changez le schéma de la base de données, ce code devient rouge immédiatement. Pas de surprises au runtime.
Pourquoi le .reverse() ? Nous interrogeons les messages par ordre décroissant (les plus récents en premier) mais les LLM s'attendent à un ordre chronologique (les plus anciens en premier). C'est un petit détail qui évite des conversations confuses.
Ătape 5 : L'Interface
Dans apps/nextjs/src/app/chat/page.tsx, on connecte le tout. Comme nous utilisons tRPC, nous obtenons React Query gratuitement. useQuery gÚre la récupération, la mise en cache et les états de chargement sans que nous ayons à écrire un seul useEffect pour le data fetching.
(J'ai inclus un useEffect uniquement pour faire dĂ©filer vers le bas â parce que l'UX, c'est important).
"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>
);
}
N'oubliez pas la page d'accueil. Mettez Ă jour 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>
);
}
Lancez pnpm dev et visitez http://localhost:3000. Cliquez sur "Start Chatting" et vous avez un chatbot IA fonctionnel.
La magie de tRPC : Remarquez comment nous n'avons jamais Ă©crit de requĂȘte API ? Pas d'appels fetch(), pas de chaĂźnes d'URL, pas de gestion d'erreur manuelle. TypeScript sait ce que sendMsg.mutate() attend. Si vous changez le schĂ©ma d'entrĂ©e du backend, votre frontend dĂ©clenchera une erreur de compilation. C'est ça, le futur.
Ătape 6 : Injecter une Ăme (Le "Vibe" Check)
Un assistant générique, c'est ennuyeux. Un assistant générique finit à la poubelle. La beauté des LLM, c'est qu'ils sont d'excellents acteurs de jeux de rÎle.
J'ai découvert que donner une opinion tranchée à votre bot le rend 10 fois plus engageant. Ne promptez pas juste « Tu es utile ». Promptez pour une personnalité.
Modifions le backend pour accepter un personnage. Mettez Ă jour 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
};
Mettez à jour le frontend pour passer la sélection du personnage :
// 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>
Maintenant, vous n'avez pas juste construit un chatbot ; vous avez construit une plateforme d'interaction avec des personnages. Ăa, c'est un produit.
Les détails techniques qui vous intéressent vraiment
Pourquoi ne pas juste utiliser Prisma ?
Prisma est gĂ©nial, mais Drizzle est plus rapide. On parle de performances de requĂȘte 2 Ă 3 fois supĂ©rieures. Quand vous ĂȘtes un fondateur solo, chaque milliseconde s'accumule. De plus, la syntaxe proche du SQL de Drizzle signifie moins de charge mentale.
Quid des réponses en streaming ?
Le Vercel AI SDK supporte le streaming nativement. Remplacez generateText par streamText et utilisez le hook useChat sur le frontend. Je l'ai sautĂ© ici car pour un tutoriel, le modĂšle requĂȘte/rĂ©ponse est plus simple. Mais en production ? Streamez tout. Les utilisateurs perçoivent le streaming comme "plus rapide" mĂȘme si le temps total est le mĂȘme.
Gestion de la fenĂȘtre de contexte
Pour l'instant, nous rĂ©cupĂ©rons les 10 derniers messages. Ăa marche jusqu'Ă ce que ça ne marche plus. Si vous construisez un produit sĂ©rieux, implĂ©mentez un compteur de tokens et ajustez l'historique dynamiquement. Le AI SDK a des utilitaires pour ça.
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Control costs
// ...
});
Pooling de connexions à la base de données
Postgres local est trĂšs bien pour le dev. Pour la production, utilisez Vercel Postgres ou Supabase. Ils gĂšrent le pooling de connexions automatiquement. Serverless + connexions base de donnĂ©es est un piĂšge â ne gĂ©rez pas ça vous-mĂȘme.
Points clés à retenir
Si vous lisez ceci et que vous ressentez l'envie de coder, voici mon conseil :
- Ne partez pas de zéro. Le boilerplate est l'ennemi du momentum. Utilisez T3 Turbo ou une structure similaire.
- La sĂ©curitĂ© du typage, c'est de la vitesse. Ăa semble plus lent la premiĂšre heure, et plus rapide pour les dix annĂ©es suivantes. Ăa attrape les bugs qui surviennent habituellement pendant une dĂ©mo.
- Le contexte est la clé. Un chatbot sans historique est juste une barre de recherche sophistiquée. Passez toujours les derniers messages au LLM.
- Personnalité > fonctionnalités. Un bot qui parle comme Tony Stark obtiendra plus d'engagement qu'un bot générique avec 10 fonctionnalités supplémentaires.
La réalité désordonnée
Construire tout ça n'a pas été un long fleuve tranquille. J'ai initialement foiré la chaßne de connexion à la base de données et passé 20 minutes à me demander pourquoi Drizzle me criait dessus. J'ai aussi atteint une limite de taux sur Gemini parce que j'envoyais trop d'historique au début (leçon : commencez toujours avec .limit(5) et augmentez ensuite).
L'animation de chargement ? Il m'a fallu trois essais pour l'avoir correctement parce que les animations CSS restent, d'une certaine maniĂšre, de la magie noire en 2024.
Mais voici le truc : parce que j'utilisais une stack robuste, c'étaient des problÚmes de logique, pas des problÚmes structurels. Les fondations ont tenu bon. Je n'ai jamais eu à refactoriser toute l'API parce que j'avais choisi la mauvaise abstraction.
Shippez-le
Nous vivons un ùge d'or pour les bùtisseurs. Les outils sont puissants, l'IA est intelligente et la barriÚre à l'entrée n'a jamais été aussi basse.
Vous avez le code maintenant. Vous avez la stack. Vous comprenez les compromis.
Allez construire quelque chose qui ne devrait pas exister, et shippez-le avant le dĂźner.
Temps total de construction : ~2 heures Lignes de code réel écrites : ~200 Bugs rencontrés en production : 0 (pour l'instant)
La stack T3 + Gemini n'est pas juste rapide â elle est ennuyeuse dans le meilleur sens du terme. Pas de surprises. Pas de "ça marche sur ma machine". Juste de la construction.
Bon code.
Ressources :
Code complet : github.com/giftedunicorn/my-chatbot
Partager ceci

Feng Liu
shenjian8628@gmail.com