Osa 1: Rakenna chatbot T3 Turbolla ja Geminillä

Useimmat perustajat jäävät jumiin "setup-helvettiin". Rakensin juuri täysin tyypitetyn AI-chatbotin yhdessä iltapäivässä. Tässä on tarkka tech stack – Next.js, tRPC ja Gemini – sekä koodit, joilla teet saman itse.

Osa 1: Rakenna chatbot T3 Turbolla ja Geminillä
Feng LiuFeng Liu
12. joulukuuta 2025

Title: Monimutkaisuus tappaa startupit: Näin rakennat AI-chatbotin T3 Turbolla ja Geminillä

Excerpt: Unohda viikkojen konfigurointi. Tässä oppaassa Feng Liu näyttää, kuinka rakennat tuotantovalmiin AI-chatbotin tunneissa käyttämällä "Vibe Coder" -työkalupakkia: T3 Turboa ja Google Geminiä.


Monimutkaisuus on alkuvaiheen startupien hiljainen tappaja. Aloitat yksinkertaisella idealla – "Haluan chatbotin, joka puhuu kuin Tony Stark" – ja kolme viikkoa myöhemmin säädät edelleen Webpackia, taistelet Docker-konttien kanssa tai debuggaat tunnistautumisprosessia, jota kukaan ei ole vielä edes käyttänyt.

Olen nähnyt loistavien insinöörien lankeavan tähän ansaan kerta toisensa jälkeen. Me rakastamme työkalujamme. Rakastamme optimointia. Mutta startup-pelissä shippaaminen (tuotantoon vieminen) on ainoa mittari, jolla on väliä.

Jos et ole tutustunut moderniin TypeScript-ekosysteemiin viime aikoina, saatat yllättyä. Ajat, jolloin parsittiin kasaan erillisiä rajapintoja ja rukoiltiin niiden pysyvän koossa, ovat pitkälti takanapäin. Olemme siirtyneet "Vibe Coderin" aikakauteen – jossa matka ideasta julkaistuun tuotteeseen mitataan tunneissa, ei sprinteissä.

Tänään käyn kanssasi läpi teknologiapinon (tech stack), joka tuntuu huijauskoodilta: Create T3 Turbo yhdistettynä Googlen Gemini AI:hin. Se on tyyppiturvallinen tietokannasta frontendiin asti, naurettavan nopea, ja rehellisesti sanottuna se tuo ilon takaisin koodaamiseen.

Miksi tällä pinolla on väliä?

Saatat miettiä: "Feng, miksi taas uusi pino? Enkö voi vain käyttää Pythonia ja Streamlitia?"

Toki, jos teet prototyyppiä. Mutta jos rakennat tuotetta – jotain, jonka täytyy skaalautua, käsitellä käyttäjiä ja ylläpitää tilaa – tarvitset oikean arkkitehtuurin. Ongelmana on, että "oikea arkkitehtuuri" tarkoittaa yleensä viikkojen edestä boilerplate-koodia.

T3 Stack (Next.js, tRPC, Tailwind) kääntää tämän asetelman päälaelleen. Se antaa sinulle full-stack-sovelluksen vakauden, mutta skriptin kehitysnopeudella. Kun lisäät mukaan Drizzle ORM:n (kevyt, SQL-mäinen) ja Google Geminin (nopea, antelias ilmaisversio), sinulla on työkalupakki, jonka avulla yksinyrittäjä voi päihittää kymmenen hengen tiimin.

Rakennetaanpa jotain todellista.

Vaihe 1: Yhden komennon asennus

Unohda ESLintin ja Prettierin manuaalinen konfigurointi. Käytämme create-t3-turbo -komentoa. Tämä pystyttää monorepo-rakenteen, mikä on täydellistä, koska se erottaa API-logiikkasi Next.js-frontendista. Tämä varmistaa tulevaisuuden siltä varalta, kun väistämättä julkaiset React Native -mobiilisovelluksen myöhemmin.

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

Kun kysytään, valitsin Next.js:n, tRPC:n ja PostgreSQL:n. Jätin Auth-tunnistautumisen toistaiseksi pois, koska optimoimme nyt julkaisunopeutta, emme täydellisyyttä. Voit lisätä NextAuthin myöhemmin kymmenessä minuutissa.

Monorepo-rakenne, jonka saat:

my-chatbot/
├── apps/nextjs/          # Web-sovelluksesi
├── packages/
│   ├── api/              # tRPC-reitittimet (jaettu logiikka)
│   ├── db/               # Tietokantaskeema + Drizzle
│   └── ui/               # Jaetut komponentit

Tämä erottelu tarkoittaa, että API-logiikkaasi voidaan käyttää uudelleen webissä, mobiilissa tai jopa CLI-sovelluksissa. Olen nähnyt tiimien tuhlaavan kuukausia refaktorointiin, koska he aloittivat laittamalla kaiken yhteen kansioon.

Vaihe 2: Aivot (Gemini)

OpenAI on loistava, mutta oletko kokeillut Gemini Flashia? Se on uskomattoman nopea ja hinnoittelu on aggressiivinen. Chat-käyttöliittymässä, jossa viive tappaa fiiliksen, nopeus on ominaisuus.

Miksi Gemini Flash eikä GPT-3.5/4?

  • Nopeus: ~800ms vs 2-3s vastausaika
  • Hinta: 60x halvempi kuin GPT-4
  • Konteksti: 1 miljoonan tokenin konteksti-ikkuna (kyllä, miljoona)

Tarvitsemme AI SDK:n, jotta LLM:ien kanssa keskustelu on standardoitua.

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

Määritä .env-tiedostosi projektin juuressa. Älä yliajattel tietokantaa paikallisesti; paikallinen Postgres-instanssi riittää hyvin.

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

Pro-vinkki: Hae Gemini API -avaimesi osoitteesta https://aistudio.google.com/app/apikey. Ilmaisversio on absurdin antelias – 60 pyyntöä minuutissa. Saavutat Product-Market Fitin (PMF) ennen kuin osut kyselyrajoihin.

Vaihe 3: Määrittele todellisuus (Skeema)

Tässä Drizzle loistaa. Vanhoina hyvinä aikoina kirjoitit migraatiot käsin. Nyt määrittelet skeeman TypeScriptillä, ja tietokanta tottelee.

Tiedostossa packages/db/src/schema.ts määrittelemme, mikä "Viesti" (Message) on. Huomaatko, kuinka käytämme drizzle-zod-kirjastoa? Tämä luo automaattisesti validointiskeemat API:llemme. Tämä on "Don't Repeat Yourself" -periaate toiminnassa.

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

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

// Zod-skeema, joka on generoitu automaattisesti taulun määrittelystä
export const CreateMessageSchema = createInsertSchema(Message, {
  role: z.enum(["user", "assistant"]),
  content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });

Puske se kantaan: pnpm db:push. Valmis. Tietokantasi on nyt olemassa.

Mitä juuri tapahtui? Drizzle katsoi TypeScript-määrittelyäsi ja loi taulun. Ei kirjoitettua SQL:ää. Ei migraatiotiedostoja. Tämä on skeema-ohjatun kehityksen taikaa.

Jos haluat varmistaa asian, aja: pnpm db:studio, niin näet web-käyttöliittymän osoitteessa https://local.drizzle.studio, jossa message-taulusi odottaa dataa.

Vaihe 4: Hermosto (tRPC)

Tämä on se osa, joka yleensä räjäyttää ihmisten tajunnan. RESTin tai GraphQL:n kanssa joudut määrittelemään päätepisteet, tyypit ja hakufunktiot erikseen. tRPC:n kanssa backend-funktiosi on frontend-funktiosi.

Luomme proseduurin, joka tallentaa käyttäjän viestin, hakee historian (konteksti on kuningas tekoälyssä), lähettää sen Geminille ja tallentaa vastauksen.

Luo 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. Tallenna käyttäjän viesti
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. Hae konteksti (Viimeiset 10 viestiä)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. Kysy Geminiltä
      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. Tallenna tekoälyn vastaus
      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;

Rekisteröi reititin tiedostossa 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;

Katso tuota virtausta. Se on lineaarinen, luettava ja täysin tyypitetty. Jos muutat tietokantaskeemaa, tämä koodi muuttuu punaiseksi välittömästi. Ei yllätyksiä ajonaikana.

Miksi .reverse()? Haemme viestit laskevassa järjestyksessä (uusin ensin), mutta LLM:t odottavat kronologista järjestystä (vanhin ensin). Se on pieni yksityiskohta, joka estää sekavat keskustelut.

Modulaarisen arkkitehtuurin visualisointi

Vaihe 5: Käyttöliittymä

Tiedostossa apps/nextjs/src/app/chat/page.tsx kytkemme kaiken yhteen. Koska käytämme tRPC:tä, saamme React Queryn kaupan päälle. useQuery hoitaa hakemisen, välimuistin ja lataustilat ilman, että meidän tarvitsee kirjoittaa yhtäkään useEffect-koukkua datan hakemiseen.

(Olen sisällyttänyt useEffect:in vain automaattista alas vierittämistä varten – koska UX on tärkeää).

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

  // Automaattinen datan haku välimuistilla
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // Mutaatio optimistisilla päivityksillä
  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);
      },
    }),
  );

  // Automaattinen vieritys uusimpaan viestiin
  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>
  );
}

Älä unohda etusivua. Päivitä 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>
  );
}

Aja pnpm dev ja mene osoitteeseen http://localhost:3000. Klikkaa "Start Chatting" ja sinulla on toimiva AI-chatbot.

tRPC:n taika: Huomaa, ettemme koskaan kirjoittaneet API-hakua. Ei fetch()-kutsuja, ei URL-merkkijonoja, ei manuaalista virheenkäsittelyä. TypeScript tietää, mitä sendMsg.mutate() odottaa. Jos muutat backendin syöteskeemaa, frontendisi heittää käännösvirheen. Tämä on tulevaisuutta.

Vaihe 6: Sielun lisääminen ("Vibe Check")

Geneerinen avustaja on tylsä. Geneerinen avustaja poistetaan. LLM:ien kauneus piilee siinä, että ne ovat erinomaisia roolipeleissä.

Olen huomannut, että vahvan mielipiteen antaminen botille tekee siitä 10x kiinnostavamman. Älä kehota vain "Ole avulias." Kehota ottamaan persoona.

Muokataan backendia hyväksymään persoona. Päivitä 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 }) => {
      // Valitse persoonallisuus
      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, // ← Dynaaminen kehote
        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();
    }),

  // ... loput pysyvät samana
};

Päivitä frontend välittämään hahmon valinta:

// ChatPage-komponentissa, lisää tila hahmolle
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// Päivitä mutaatiokutsu
sendMsg.mutate({ content: input.trim(), character });

// Lisää pudotusvalikko ennen syötekenttää:
<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>

Nyt et ole rakentanut vain chatbotia; olet rakentanut hahmojen vuorovaikutusalustan. Se on tuote.

Tekniset yksityiskohdat, joista oikeasti välität

Miksi ei vain käytetä Prismaa?

Prisma on loistava, mutta Drizzle on nopeampi. Puhumme 2-3x kyselysuorituskyvystä. Kun olet yksinyrittäjä, jokainen millisekunti kertautuu. Lisäksi Drizzlen SQL-mäinen syntaksi tarkoittaa vähemmän henkistä kuormaa.

Entä vastausten striimaus?

Vercel AI SDK tukee striimausta suoraan paketista. Korvaa generateText funktiolla streamText ja käytä useChat-koukkua frontendissa. Jätin sen tästä pois, koska opetusohjelmassa pyyntö/vastaus-malli on yksinkertaisempi. Mutta tuotannossa? Striimaa kaikki. Käyttäjät kokevat striimauksen "nopeammaksi", vaikka kokonaisaika olisi sama.

Konteksti-ikkunan hallinta

Tällä hetkellä haemme viimeiset 10 viestiä. Se toimii, kunnes se ei enää toimi. Jos rakennat vakavaa tuotetta, toteuta token-laskuri ja säädä historiaa dynaamisesti. AI SDK:ssa on apuohjelmia tähän.

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

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

Tietokantayhteyksien poolaus

Paikallinen Postgres on ok kehitykseen. Tuotantoa varten käytä Vercel Postgresia tai Supabasea. Ne hoitavat yhteyksien poolauksen automaattisesti. Serverless + tietokantayhteydet on ansa – älä hallinnoi sitä itse.

Käytännön opit

Jos luet tätä ja sormesi syyhyävät koodaamaan, tässä on neuvoni:

  1. Älä aloita tyhjästä. Boilerplate on vauhdin vihollinen. Käytä T3 Turboa tai vastaavaa rakennustelinettä.
  2. Tyyppiturvallisuus on nopeutta. Se tuntuu hitaammalta ensimmäisen tunnin ajan, ja nopeammalta seuraavat kymmenen vuotta. Se nappaa bugit, jotka yleensä ilmenevät demon aikana.
  3. Konteksti on avain. Chatbot ilman historiaa on vain hieno hakukenttä. Välitä aina viimeiset viestit LLM:lle.
  4. Persoona > ominaisuudet. Botti, joka kuulostaa Tony Starkilta, saa enemmän sitoutumista kuin geneerinen botti kymmenellä lisäominaisuudella.

Suttuinen todellisuus

Tämän rakentaminen ei ollut pelkkää ruusuilla tanssimista. Sössin aluksi tietokannan yhteysmerkkijonon ja ihmettelin 20 minuuttia, miksi Drizzle huusi minulle. Osui myös Geminin kyselyrajoihin (rate limit), koska lähetin aluksi liikaa historiaa (opetus: aloita aina .limit(5) ja skaalaa ylöspäin).

Latausanimaatio? Sen saaminen kohdalleen vei kolme yritystä, koska CSS-animaatiot ovat edelleen, jotenkin kummassa, mustaa magiaa vuonna 2024.

Mutta tässä on se juttu: koska käytin vankkaa pinoa, nämä olivat logiikkaongelmia, eivät rakenteellisia ongelmia. Perusta piti. Minun ei koskaan tarvinnut refaktoroida koko API:a, koska olisin valinnut väärän abstraktion.

Julkaise se (Ship It)

Elämme rakentamisen kulta-aikaa. Työkalut ovat tehokkaita, tekoäly on fiksua, ja kynnys aloittamiseen ei ole koskaan ollut matalampi.

Sinulla on koodi nyt. Sinulla on pino. Ymmärrät kompromissit.

Mene rakentamaan jotain, mitä ei pitäisi olla olemassa, ja julkaise se ennen päivällistä.

Kokonaisrakennusaika: ~2 tuntia Kirjoitettuja koodirivejä: ~200 Bugeja tuotannossa: 0 (toistaiseksi)

T3-pino + Gemini ei ole vain nopea – se on tylsä parhaalla mahdollisella tavalla. Ei yllätyksiä. Ei "toimii minun koneellani" -ongelmia. Vain rakentamista.

Iloista koodausta.


Resurssit:

Koko koodi: github.com/giftedunicorn/my-chatbot

Jaa tämä

Feng Liu

Feng Liu

shenjian8628@gmail.com

Osa 1: Rakenna chatbot T3 Turbolla ja Geminillä | Feng Liu