Del 1: SÄdan bygger du en chatbot med T3 Turbo & Gemini

De fleste founders ender i 'setup-helvede'. Jeg byggede en fuldt typesikker AI-chatbot pĂ„ en enkelt eftermiddag. Her er den prĂŠcise stack – Next.js, tRPC og Gemini – og koden, sĂ„ du selv kan bygge den.

Del 1: SÄdan bygger du en chatbot med T3 Turbo & Gemini
Feng LiuFeng Liu
12. december 2025

Title: Kompleksitet er den stille drĂŠber: Byg en AI-Chatbot med T3 Stack og Gemini

Excerpt: Kompleksitet drÊber startups. I denne guide viser Feng Liu, hvordan du bygger en produktionsklar AI-chatbot pÄ fÄ timer ved hjÊlp af T3 Stack og Google Gemini. Ingen boilerplate, bare ren byggeglÊde.


Kompleksitet er den stille drĂŠber for early-stage startups. Du starter med en simpel idĂ© – "Jeg vil have en chatbot, der taler som Tony Stark" – og tre uger senere sidder du stadig og konfigurerer Webpack, kĂŠmper med Docker-containere eller debugger et login-flow, som ingen har brugt endnu.

Det er en fÊlde, jeg har set genialt talentfulde ingeniÞrer falde i gang pÄ gang. Vi elsker vores vÊrktÞjer. Vi elsker at optimere. Men i startup-gamet er shipping den eneste metric, der tÊller.

Hvis du ikke har kigget pĂ„ det moderne TypeScript-Ăžkosystem for nylig, bliver du mĂ„ske overrasket. Tiden, hvor vi klistrede forskellige API'er sammen og bad til, at det holdt, er stort set forbi. Vi er trĂ„dt ind i "Vibe Coder"-ĂŠraen – hvor afstanden mellem en idĂ© og et deployet produkt mĂ„les i timer, ikke i sprints.

I dag vil jeg guide dig gennem en stack, der fĂžles som en snydekode: Create T3 Turbo kombineret med Google's Gemini AI. Det er typesikkert fra databasen til frontenden, det er latterligt hurtigt, og helt ĂŠrligt: det bringer glĂŠden tilbage ved at kode.

Hvorfor denne stack betyder noget

Du tÊnker mÄske: "Feng, hvorfor endnu en stack? Kan jeg ikke bare bruge Python og Streamlit?"

Jo, til en prototype. Men hvis du bygger et produkt – noget der skal skalere, hĂ„ndtere brugere og bevare state – har du brug for en rigtig arkitektur. Problemet er, at "rigtig arkitektur" normalt betyder "ugevis af boilerplate."

T3 Stacken (Next.js, tRPC, Tailwind) vender op og ned pÄ dette. Den giver dig robustheden fra en full-stack applikation med udviklingshastigheden fra et script. NÄr du tilfÞjer Drizzle ORM (letvÊgts, SQL-agtig) og Google Gemini (hurtig, generÞs gratis tier), har du en vÊrktÞjskasse, der lader en solostifter udmanÞvrere et team pÄ ti.

Lad os bygge noget ĂŠgte.

Trin 1: OpsÊtning med én kommando

Glem alt om manuelt at konfigurere ESLint og Prettier. Vi bruger create-t3-turbo. Dette opsÊtter en monorepo-struktur, hvilket er perfekt, fordi det adskiller din API-logik fra din Next.js frontend. Det fremtidssikrer dig til den dag, du uundgÄeligt lancerer en React Native mobil-app.

pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install

Da jeg blev spurgt, valgte jeg Next.js, tRPC og PostgreSQL. Jeg sprang Auth over for nu, fordi vi igen optimerer for at shippe, ikke for perfektion. Du kan tilfÞje NextAuth senere pÄ ti minutter.

Monorepo-strukturen du fÄr:

my-chatbot/
├── apps/nextjs/          # Din web app
├── packages/
│   ├── api/              # tRPC routers (delt logik)
│   ├── db/               # Database schema + Drizzle
│   └── ui/               # Delte komponenter

Denne adskillelse betyder, at din API-logik kan genbruges pÄ tvÊrs af web, mobil eller endda CLI-apps. Jeg har set teams spilde mÄneder pÄ refactoring, fordi de startede med alt i én mappe.

Trin 2: Hjernen (Gemini)

OpenAI er fantastisk, men har du prĂžvet Gemini Flash? Den er utrolig hurtig, og prissĂŠtningen er aggressiv. For et chat-interface, hvor latency drĂŠber viben, er hastighed en feature.

Hvorfor Gemini Flash frem for GPT-3.5/4?

  • Hastighed: ~800ms vs 2-3s responstid
  • Pris: 60x billigere end GPT-4
  • Kontekst: 1M token kontekstvindue (ja, en million)

Vi har brug for AI SDK'et til at standardisere kommunikationen med LLM'er.

cd packages/api
pnpm add ai @ai-sdk/google

OpsĂŠt din .env i projektets rod. OvertĂŠnk ikke databasen lokalt; en lokal Postgres-instans er fint.

POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="din_nĂžgle_her"

Pro tip: Hent din Gemini API-nþgle fra https://aistudio.google.com/app/apikey. Den gratis tier er absurd generþs – 60 requests i minuttet. Du rammer product-market fit, fþr du rammer rate limits.

Trin 3: Definér virkeligheden (Schemaet)

Det er her, Drizzle skinner igennem. I gamle dage skrev du migrationer i hÄnden. Nu definerer du dit schema i TypeScript, og databasen adlyder.

I packages/db/src/schema.ts definerer vi, hvad en "Message" er. LĂŠg mĂŠrke til, hvordan vi bruger drizzle-zod? Dette skaber automatisk validerings-schemaer til vores API. Det er "Don't Repeat Yourself"-princippet i aktion.

import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";

// Message tabel til chatbot
export const Message = pgTable("message", (t) => ({
  id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
  role: t.varchar({ length: 20 }).notNull(), // 'user' eller 'assistant'
  content: t.text().notNull(),
  createdAt: t.timestamp().defaultNow().notNull(),
}));

// Zod schema auto-genereret fra tabel-definitionen
export const CreateMessageSchema = createInsertSchema(Message, {
  role: z.enum(["user", "assistant"]),
  content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });

Push det: pnpm db:push. FĂŠrdig. Din database eksisterer nu.

Hvad skete der lige? Drizzle kiggede pÄ din TypeScript-definition og oprettede tabellen. Ingen SQL skrevet. Ingen migrationsfiler. Det er magien ved schema-drevet udvikling.

Hvis du vil verificere, sÄ kÞr: pnpm db:studio, og du vil se en web-UI pÄ https://local.drizzle.studio med din message-tabel, klar til at modtage data.

Trin 4: Nervesystemet (tRPC)

Det er her, folk ofte bliver blĂŠst bagover. Med REST eller GraphQL skal du definere endpoints, typer og fetchers separat. Med tRPC er din backend-funktion din frontend-funktion.

Vi opretter en procedure, der gemmer brugerens besked, henter historik (kontekst er konge inden for AI), sender det til Gemini og gemmer svaret.

Opret 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. Gem brugerbesked
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. Hent kontekst (Sidste 10 beskeder)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. SpĂžrg 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. Gem AI-svar
      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;

Registrer routeren 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;

Se pÄ det flow. Det er lineÊrt, lÊsbart og fuldt typet. Hvis du Êndrer databaseschemaet, bliver denne kode rÞd med det samme. Ingen runtime-overraskelser.

Hvorfor .reverse()? Vi forespĂžrger beskeder i faldende rĂŠkkefĂžlge (nyeste fĂžrst), men LLM'er forventer kronologisk rĂŠkkefĂžlge (ĂŠldste fĂžrst). Det er en lille detalje, der forhindrer forvirrende samtaler.

Modular Architecture Visualization

Trin 5: Interfacet

I apps/nextjs/src/app/chat/page.tsx kobler vi det sammen. Fordi vi bruger tRPC, fÄr vi React Query forÊrende. useQuery hÄndterer fetching, caching og loading states, uden at vi skriver en eneste useEffect til datahentning.

(Jeg har kun inkluderet en useEffect for at scrolle til bunden – fordi UX betyder noget).

"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();

  // Automatisk datahentning med caching
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutation med optimistiske opdateringer
  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 til seneste besked
  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>

      {/* Beskeder */}
      <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="Skriv din besked..."
            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>
  );
}

Glem ikke forsiden. Opdater 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">Bygget med 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 Chat
        </Link>
      </div>
    </main>
  );
}

KÞr pnpm dev og besÞg http://localhost:3000. Klik pÄ "Start Chat", og du har en fungerende AI-chatbot.

Magien ved tRPC: LÊg mÊrke til, at vi aldrig skrev et API-kald? Ingen fetch()-kald, ingen URL-strenge, ingen manuel fejlhÄndtering. TypeScript ved, hvad sendMsg.mutate() forventer. Hvis du Êndrer backend input-schemaet, vil din frontend smide en compile-fejl. Det her er fremtiden.

Trin 6: IndsprĂžjtning af sjĂŠl ("Vibe"-tjekket)

En generisk assistent er kedelig. En generisk assistent bliver slettet. SkĂžnheden ved LLM'er er, at de er fremragende rollespillere.

Jeg har fundet ud af, at hvis du giver din bot en stĂŠrk holdning, bliver den 10x mere engagerende. Prompt ikke bare "Du er hjĂŠlpsom." Prompt efter en personlighed.

Lad os ĂŠndre backenden til at acceptere en persona. Opdater 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 }) => {
      // VĂŠlg personligheden
      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, // ← Dynamisk 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();
    }),

  // ... resten forbliver det samme
};

Opdater frontenden til at sende karaktervalget med:

// I ChatPage komponenten, tilfĂžj state for karakter
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Opdater mutation kaldet
sendMsg.mutate({ content: input.trim(), character });

// TilfĂžj en dropdown fĂžr inputtet:
<select
  value={character}
  onChange={(e) => setCharacter(e.target.value as any)}
  className="rounded-lg border px-3 py-2"
>
  <option value="default">đŸ€– Standard</option>
  <option value="luffy">👒 Luffy</option>
  <option value="stark">đŸŠŸ Tony Stark</option>
</select>

Nu har du ikke bare bygget en chatbot; du har bygget en platform for karakterinteraktion. Det er et produkt.

De tekniske detaljer du faktisk bekymrer dig om

Hvorfor ikke bare bruge Prisma?

Prisma er fint, men Drizzle er hurtigere. Vi taler 2-3x query performance. NÄr du er solostifter, tÊller hvert millisekund. Plus, Drizzles SQL-lignende syntaks betyder mindre mentalt overhead.

Hvad med streaming af svar?

Vercel AI SDK understÞtter streaming ud af boksen. Erstat generateText med streamText og brug useChat hooket pÄ frontenden. Jeg sprang det over her, fordi request/response er simplere til en tutorial. Men i produktion? Stream alt. Brugere opfatter streaming som "hurtigere", selv nÄr den totale tid er den samme.

Styring af kontekstvindue

Lige nu henter vi de sidste 10 beskeder. Det virker, indtil det ikke gÞr. Hvis du bygger et seriÞst produkt, sÄ implementer en token-tÊller og juster historikken dynamisk. AI SDK'et har vÊrktÞjer til dette.

import { anthropic } from "@ai-sdk/anthropic";

const { text } = await generateText({
  model: anthropic("claude-3-5-sonnet-20241022"),
  maxTokens: 1000, // Kontroller omkostninger
  // ...
});

Database connection pooling

Lokal Postgres er fint til dev. Til produktion, brug Vercel Postgres eller Supabase. De hĂ„ndterer connection pooling automatisk. Serverless + databaseforbindelser er en fĂŠlde – lad vĂŠre med at styre det selv.

Praktiske takeaways

Hvis du lÊser dette og det kribler i fingrene for at kode, er her mit rÄd:

  1. Start ikke fra bunden. Boilerplate er momentum-drĂŠberen. Brug T3 Turbo eller lignende stilladser.
  2. Typesikkerhed er hastighed. Det fÞles langsommere den fÞrste time, og hurtigere de nÊste ti Är. Det fanger de bugs, der normalt opstÄr under en demo.
  3. Kontekst er nĂžglen. En chatbot uden historik er bare en fancy sĂžgebjĂŠlke. Send altid de sidste par beskeder til LLM'en.
  4. Personlighed > features. En bot, der lyder som Tony Stark, vil fÄ mere engagement end en generisk bot med 10 ekstra features.

Den rodede virkelighed

At bygge dette var ikke en dans pÄ roser. Jeg kludrede i databaseforbindelsesstrengen i starten og brugte 20 minutter pÄ at undre mig over, hvorfor Drizzle rÄbte ad mig. Jeg ramte ogsÄ en rate limit pÄ Gemini, fordi jeg sendte for meget historik i starten (lektie: start altid med .limit(5) og skaler op).

Loading-animationen? Det tog mig tre forsÞg at fÄ den rigtig, fordi CSS-animationer pÄ en eller anden mÄde stadig er sort magi i 2024.

Men her er sagen: fordi jeg brugte en robust stack, var det logiske problemer, ikke strukturelle problemer. Fundamentet holdt. Jeg behĂžvede aldrig at refactorere hele API'et, fordi jeg valgte den forkerte abstraktion.

Ship det

Vi lever i en guldalder for bygherrer. VĂŠrktĂžjerne er kraftfulde, AI'en er klog, og adgangsbarrieren har aldrig vĂŠret lavere.

Du har koden nu. Du har stacken. Du forstÄr tradeoffs.

GĂ„ ud og byg noget, der ikke burde eksistere, og ship det inden aftensmaden.

Total byggetid: ~2 timer Linjers faktisk kode skrevet: ~200 Bugs mĂždt i produktion: 0 (indtil videre)

T3 stacken + Gemini er ikke bare hurtig – den er kedelig pĂ„ den bedst mulige mĂ„de. Ingen overraskelser. Ingen "det virker pĂ„ min maskine." Bare byggeri.

God kodelyst.


Ressourcer:

Fuld kode: github.com/giftedunicorn/my-chatbot

Del dette

Feng Liu

Feng Liu

shenjian8628@gmail.com

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