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

Écrit par Feng Liu
shenjian8628@gmail.com