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.

Del 1: SĂ„ bygger du en chatbot med T3 Turbo & Gemini
Feng LiuFeng Liu
12 december 2025

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.

Modular Architecture Visualization

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:

  1. Börja inte frÄn noll. Boilerplate Àr momentumets fiende. AnvÀnd T3 Turbo eller liknande byggnadsstÀllningar.
  2. 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.
  3. Kontext Àr nyckeln. En chatbot utan historik Àr bara ett fancy sökfÀlt. Skicka alltid med de senaste meddelandena till LLM:en.
  4. 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

Feng Liu

shenjian8628@gmail.com

Del 1: SĂ„ bygger du en chatbot med T3 Turbo & Gemini | Feng Liu