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