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