Del 1: Slik bygger du en chatbot med T3 Turbo & Gemini

De fleste grĂŒndere kjĂžrer seg fast i "setup-helvete". Jeg bygde nettopp en helt typesikker AI-chatbot pĂ„ Ă©n ettermiddag. Her er den nĂžyaktige stacken – Next.js, tRPC og Gemini – og koden du trenger for Ă„ gjĂžre det selv.

Del 1: Slik bygger du en chatbot med T3 Turbo & Gemini
Feng LiuFeng Liu
12. desember 2025

Kompleksitet er den stille morderen av startups i tidlig fase. Du starter med en enkel idĂ© – "Jeg vil ha en chatbot som snakker som Tony Stark" – og tre uker senere sitter du fortsatt og konfigurerer Webpack, slĂ„ss med Docker-containere, eller debugger en autentiseringsflyt som ingen har brukt ennĂ„.

Det er en felle jeg har sett briljante ingeniÞrer gÄ i gang pÄ gang. Vi elsker verktÞyene vÄre. Vi elsker Ä optimalisere. Men i startup-gamet er Ä shippe den eneste mÄlestokken som betyr noe.

Hvis du ikke har sett pĂ„ det moderne TypeScript-Ăžkosystemet i det siste, vil du kanskje bli overrasket. Dagene med Ă„ sy sammen ulike API-er og be til hĂžyere makter om at de holder sammen, er stort sett bak oss. Vi har gĂ„tt inn i "Vibe Coder"-ĂŠraen – hvor avstanden mellom en idĂ© og et lansert produkt mĂ„les i timer, ikke sprinter.

I dag skal jeg guide deg gjennom en tech stack som fþles som en juksekode: Create T3 Turbo kombinert med Googles Gemini AI. Den er typesikker fra databasen til frontend, den er latterlig rask, og érlig talt – den bringer gleden tilbake til kodingen.

Hvorfor denne stacken er viktig

Du tenker kanskje: "Feng, hvorfor enda en stack? Kan jeg ikke bare bruke Python og Streamlit?"

Klart, for en prototype. Men hvis du bygger et produkt – noe som skal skalere, hĂ„ndtere brukere og bevare tilstand – trenger du en ekte arkitektur. Problemet er at "ekte arkitektur" vanligvis betyr "uker med boilerplate-kode".

T3 Stacken (Next.js, tRPC, Tailwind) snur opp ned pĂ„ dette. Den gir deg robustheten til en fullstack-applikasjon med utviklingshastigheten til et script. NĂ„r du legger til Drizzle ORM (lettvekt, SQL-lignende) og Google Gemini (rask, sjenerĂžs gratisversjon), har du en verktĂžykasse som lar en solo-grĂŒnder utmanĂžvrere et team pĂ„ ti.

La oss bygge noe ekte.

Steg 1: Oppsett med én kommando

Glem manuell konfigurering av ESLint og Prettier. Vi skal bruke create-t3-turbo. Dette setter opp en monorepo-struktur som er perfekt fordi den skiller API-logikken din fra Next.js-frontenden, noe som fremtidssikrer deg for nÄr du uunngÄelig lanserer en React Native-mobilapp senere.

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

Da jeg ble spurt, valgte jeg Next.js, tRPC, og PostgreSQL. Jeg hoppet over Auth for nÄ, fordi vi optimaliserer for Ä shippe, ikke perfeksjonere. Du kan legge til NextAuth senere pÄ ti minutter.

Monorepo-strukturen du fÄr:

my-chatbot/
├── apps/nextjs/          # Din web-app
├── packages/
│   ├── api/              # tRPC-rutere (delt logikk)
│   ├── db/               # Databaseskjema + Drizzle
│   └── ui/               # Delte komponenter

Denne separasjonen betyr at API-logikken din kan gjenbrukes pÄ tvers av web, mobil, eller til og med CLI-apper. Jeg har sett team kaste bort mÄneder pÄ refaktorering fordi de startet med alt i én mappe.

Steg 2: Hjernen (Gemini)

OpenAI er bra, men har du prĂžvd Gemini Flash? Den er utrolig rask, og prisingen er aggressiv. For et chat-grensesnitt hvor forsinkelser dreper viben, er hastighet en funksjon i seg selv.

Hvorfor Gemini Flash over GPT-3.5/4?

  • Hastighet: ~800ms vs 2-3s responstid
  • Kostnad: 60x billigere enn GPT-4
  • Kontekst: 1M token kontekstvindu (ja, Ă©n million)

Vi trenger AI SDK-en for Ă„ standardisere kommunikasjonen med LLM-er.

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

Sett opp din .env i prosjektets rotmappe. Ikke overtenk databasen lokalt; en lokal Postgres-instans fungerer fint.

POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"

Pro-tips: Hent din Gemini API-nĂžkkel fra https://aistudio.google.com/app/apikey. GratisnivĂ„et er absurd sjenerĂžst – 60 forespĂžrsler i minuttet. Du vil nĂ„ Product-Market Fit fĂžr du treffer begrensningene.

Steg 3: Definer virkeligheten (Skjemaet)

Det er her Drizzle skinner. I gamle dager skrev du migreringer for hÄnd. NÄ definerer du skjemaet ditt i TypeScript, og databasen adlyder.

I packages/db/src/schema.ts definerer vi hva en "Message" er. Legg merke til hvordan vi bruker drizzle-zod? Dette lager automatisk valideringsskjemaer for API-et vÄrt. Dette er "Don't Repeat Yourself"-prinsippet i praksis.

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

// Message-tabell for 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-skjema autogenerert fra tabelldefinisjonen
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. Ferdig. Databasen din eksisterer nÄ.

Hva skjedde nettopp? Drizzle sÄ pÄ TypeScript-definisjonen din og opprettet tabellen. Ingen SQL skrevet. Ingen migreringsfiler. Dette er magien med skjemadrevet utvikling.

Hvis du vil verifisere, kjÞr: pnpm db:studio, og du vil se et web-grensesnitt pÄ https://local.drizzle.studio med message-tabellen din, klar til Ä motta data.

Steg 4: Nervesystemet (tRPC)

Dette er delen som vanligvis fÄr folk til Ä sperre opp Þynene. Med REST eller GraphQL mÄ du definere endepunkter, typer og fetchers separat. Med tRPC er backend-funksjonen din frontend-funksjonen din.

Vi lager en prosedyre som lagrer brukerens melding, henter historikk (kontekst er konge i AI), sender det til Gemini, og lagrer svaret.

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

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

      // 3. SpĂžr 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. Lagre 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 ruteren 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Ä den flyten. Den er lineÊr, lesbar og fullt typet. Hvis du endrer databaseskjemaet, blir denne koden rÞd umiddelbart. Ingen overraskelser under kjÞring (runtime).

Hvorfor .reverse()? Vi spĂžr etter meldinger i synkende rekkefĂžlge (nyeste fĂžrst), men LLM-er forventer kronologisk rekkefĂžlge (eldste fĂžrst). Det er en liten detalj som forhindrer forvirrende samtaler.

Modular Architecture Visualization

Steg 5: Grensesnittet

I apps/nextjs/src/app/chat/page.tsx kobler vi det sammen. Fordi vi bruker tRPC, fÄr vi React Query gratis. useQuery hÄndterer henting, caching og lastetilstander uten at vi skriver en eneste useEffect for datahenting.

(Jeg har inkludert en useEffect kun for Ă„ scrolle til bunnen – fordi UX betyr noe).

"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 datahenting med caching
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutasjon med optimistiske oppdateringer
  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 nyeste melding
  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>
  );
}

Ikke glem forsiden. Oppdater 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>
  );
}

KjĂžr pnpm dev og besĂžk http://localhost:3000. Klikk "Start Chatting", og du har en fungerende AI-chatbot.

Magien med tRPC: Legg merke til hvordan vi aldri skrev en API-fetch? Ingen fetch()-kall, ingen URL-strenger, ingen manuell feilhÄndtering. TypeScript vet hva sendMsg.mutate() forventer. Hvis du endrer input-skjemaet i backend, vil frontenden din kaste en kompileringsfeil. Dette er fremtiden.

Steg 6: Injiser sjel ("Vibe"-sjekken)

En generisk assistent er kjedelig. En generisk assistent blir slettet. Det fine med LLM-er er at de er utmerkede rollespillere.

Jeg har funnet ut at det Ă„ gi boten din en sterk mening gjĂžr den 10x mer engasjerende. Ikke bare prompt "You are helpful." Prompt for en personlighet.

La oss endre backend for Ă„ akseptere en persona. Oppdater 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 }) => {
      // Velg personligheten
      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 forblir det samme
};

Oppdater frontenden for Ă„ sende med karaktervalget:

// I ChatPage-komponenten, legg til state for karakter
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Oppdater mutasjonskallet
sendMsg.mutate({ content: input.trim(), character });

// Legg til en dropdown fĂžr input-feltet:
<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>

NĂ„ har du ikke bare bygget en chatbot; du har bygget en plattform for karakterinteraksjon. Det er et produkt.

De tekniske detaljene du faktisk bryr deg om

Hvorfor ikke bare bruke Prisma?

Prisma er bra, men Drizzle er raskere. Vi snakker 2-3x spĂžrringsytelse. NĂ„r du er en solo-grĂŒnder, teller hvert millisekund. Pluss at Drizzles SQL-lignende syntaks betyr mindre mental overhead.

Hva med strĂžmming av svar?

Vercel AI SDK stÞtter strÞmming ut av boksen. Bytt ut generateText med streamText og bruk useChat-hooken pÄ frontend. Jeg hoppet over det her fordi for en tutorial er request/response enklere. Men i produksjon? StrÞm alt. Brukere oppfatter strÞmming som "raskere" selv om totaltiden er den samme.

HÄndtering av kontekstvindu

Akkurat nÄ henter vi de siste 10 meldingene. Det fungerer helt til det ikke gjÞr det. Hvis du bygger et seriÞst produkt, implementer en tokenteller og juster historikken dynamisk. AI SDK-en har verktÞy for dette.

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

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

Database connection pooling

Lokal Postgres er fint for utvikling. For produksjon, bruk Vercel Postgres eller Supabase. De hĂ„ndterer connection pooling automatisk. Serverless + databasetilkoblinger er en felle – ikke prĂžv Ă„ administrere det selv.

Praktiske lĂŠrdommer

Hvis du leser dette og kjenner at det klÞr i fingrene etter Ä kode, her er mitt rÄd:

  1. Ikke start fra bunnen av. Boilerplate er fienden av momentum. Bruk T3 Turbo eller lignende stillas.
  2. Typesikkerhet er fart. Det fÞles tregere den fÞrste timen, og raskere de neste ti Ärene. Det fanger opp bugsene som vanligvis dukker opp under en demo.
  3. Kontekst er nĂžkkelen. En chatbot uten historikk er bare et fancy sĂžkefelt. Send alltid de siste meldingene til LLM-en.
  4. Personlighet > funksjoner. En bot som hÞres ut som Tony Stark vil fÄ mer engasjement enn en generisk bot med 10 ekstra funksjoner.

Den rotete virkeligheten

Å bygge dette var ikke bare plankekjĂžring. Jeg rotet til database-tilkoblingsstrengen i starten og brukte 20 minutter pĂ„ Ă„ lure pĂ„ hvorfor Drizzle kjeftet pĂ„ meg. Jeg traff ogsĂ„ en rate limit pĂ„ Gemini fordi jeg sendte for mye historikk i begynnelsen (lĂŠrdom: start alltid med .limit(5) og skaler opp).

Lasteanimasjonen? Den tok meg tre forsÞk Ä fÄ riktig fordi CSS-animasjoner pÄ en eller annen mÄte fortsatt er svart magi i 2024.

Men her er greia: fordi jeg brukte en robust stack, var dette logiske problemer, ikke strukturelle problemer. Fundamentet holdt stand. Jeg trengte aldri Ă„ refaktorere hele API-et fordi jeg valgte feil abstraksjon.

Shipp det

Vi lever i en gullalder for bygging. VerktĂžyene er kraftige, AI-en er smart, og inngangsbarrieren har aldri vĂŠrt lavere.

Du har koden nÄ. Du har stacken. Du forstÄr avveiningene.

GĂ„ og bygg noe som ikke burde eksistere, og shipp det fĂžr middag.

Total byggetid: ~2 timer Linjer med faktisk kode skrevet: ~200 Bugs mÞtt i produksjon: 0 (sÄ langt)

T3-stacken + Gemini er ikke bare raskt – det er kjedelig pĂ„ den beste mĂ„ten. Ingen overraskelser. Ingen "funker pĂ„ min maskin". Bare bygging.

God koding.


Ressurser:

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

Del dette

Feng Liu

Feng Liu

shenjian8628@gmail.com

Del 1: Slik bygger du en chatbot med T3 Turbo & Gemini | Feng Liu