Building a Chatbot
This guide walks you through building a full-featured AI chatbot with CraftJS.
What We’ll Build
A chatbot that:
- Streams responses in real-time
- Supports multiple AI models
- Saves chat history
- Includes rate limiting
- Has a polished UI
Prerequisites
- CraftJS project set up
- At least one AI provider configured
- Database set up
Create the Chat Schema
Add a chats table to your database schema:
// lib/db/schema.ts
export const chats = pgTable("chat", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
model: text("model").notNull().default("gpt-4o"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const messages = pgTable("message", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
chatId: text("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
role: text("role").notNull(), // "user" | "assistant"
content: text("content").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});Push the schema:
pnpm db:pushCreate the Chat API Route
// app/api/chat/route.ts
import { streamText } from "ai";
import { models, type ModelId } from "@/lib/ai/models";
import { auth } from "@/lib/auth/server";
import { rateLimit } from "@/lib/cache/rate-limiter";
import { db } from "@/lib/db/client";
import { chats, messages } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
export async function POST(req: Request) {
// Authenticate
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// Rate limit
const { success, remaining } = await rateLimit.limit(session.user.id);
if (!success) {
return new Response("Rate limit exceeded", {
status: 429,
headers: { "X-RateLimit-Remaining": remaining.toString() },
});
}
// Parse request
const { messages: chatMessages, chatId, model = "gpt-4o" } = await req.json();
// Get or create chat
let chat;
if (chatId) {
const [existingChat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
chat = existingChat;
} else {
// Create new chat with first message as title
const [newChat] = await db
.insert(chats)
.values({
userId: session.user.id,
title: chatMessages[0]?.content.slice(0, 50) || "New Chat",
model,
})
.returning();
chat = newChat;
}
// Save user message
const lastUserMessage = chatMessages[chatMessages.length - 1];
if (lastUserMessage?.role === "user") {
await db.insert(messages).values({
chatId: chat.id,
role: "user",
content: lastUserMessage.content,
});
}
// Generate response
const result = await streamText({
model: models[model as ModelId],
messages: chatMessages,
system: `You are a helpful AI assistant. Be concise and accurate.`,
onFinish: async ({ text }) => {
// Save assistant message
await db.insert(messages).values({
chatId: chat.id,
role: "assistant",
content: text,
});
},
});
return result.toDataStreamResponse({
headers: {
"X-Chat-Id": chat.id,
},
});
}Create API for Chat History
// app/api/chats/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { db } from "@/lib/db/client";
import { chats } from "@/lib/db/schema";
import { eq, desc } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userChats = await db
.select()
.from(chats)
.where(eq(chats.userId, session.user.id))
.orderBy(desc(chats.updatedAt))
.limit(50);
return NextResponse.json(userChats);
}// app/api/chats/[id]/messages/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { db } from "@/lib/db/client";
import { chats, messages } from "@/lib/db/schema";
import { eq, and, asc } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify ownership
const [chat] = await db
.select()
.from(chats)
.where(and(eq(chats.id, params.id), eq(chats.userId, session.user.id)))
.limit(1);
if (!chat) {
return NextResponse.json({ error: "Chat not found" }, { status: 404 });
}
const chatMessages = await db
.select()
.from(messages)
.where(eq(messages.chatId, params.id))
.orderBy(asc(messages.createdAt));
return NextResponse.json(chatMessages);
}Build the Chat UI
// app/(dashboard)/chat/page.tsx
"use client";
import { useChat } from "ai/react";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Bot, User, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
const models = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gemini-2-flash", name: "Gemini 2 Flash" },
];
export default function ChatPage() {
const [selectedModel, setSelectedModel] = useState("gpt-4o");
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
body: { model: selectedModel },
});
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
handleSubmit(e);
};
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<h1 className="text-xl font-semibold">AI Chat</h1>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="rounded-lg border px-3 py-2"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
{/* Messages */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="mt-8 text-center text-neutral-500 dark:text-neutral-400">
<Bot className="mx-auto mb-4 h-12 w-12 opacity-50" />
<p>Start a conversation with the AI assistant</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex max-w-3xl gap-3",
message.role === "user" ? "ml-auto flex-row-reverse" : ""
)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
message.role === "user"
? "bg-emerald-500 text-white"
: "bg-neutral-200 dark:bg-neutral-800"
)}
>
{message.role === "user" ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
<div
className={cn(
"rounded-2xl px-4 py-3",
message.role === "user"
? "bg-emerald-500 text-white"
: "bg-neutral-100 dark:bg-neutral-800"
)}
>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-800">
<Bot className="h-4 w-4" />
</div>
<div className="rounded-2xl bg-neutral-100 px-4 py-3 dark:bg-neutral-800">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)}
{error && <div className="text-center text-red-500">Error: {error.message}</div>}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={onSubmit} className="border-t p-4">
<div className="mx-auto flex max-w-3xl gap-2">
<Textarea
value={input}
onChange={handleInputChange}
placeholder="Type your message..."
className="max-h-[200px] min-h-[50px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit(e);
}
}}
/>
<Button type="submit" disabled={isLoading || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</form>
</div>
);
}Add Chat Sidebar
// components/chat/chat-sidebar.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter, usePathname } from "next/navigation";
import { MessageSquare, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Chat {
id: string;
title: string;
updatedAt: string;
}
export function ChatSidebar() {
const router = useRouter();
const pathname = usePathname();
const { data: chats, isLoading } = useQuery<Chat[]>({
queryKey: ["chats"],
queryFn: async () => {
const res = await fetch("/api/chats");
return res.json();
},
});
const handleNewChat = () => {
router.push("/chat");
};
const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
e.stopPropagation();
await fetch(`/api/chats/${chatId}`, { method: "DELETE" });
// Refetch chats
};
return (
<div className="flex h-full w-64 flex-col border-r">
<div className="p-4">
<Button onClick={handleNewChat} className="w-full">
<Plus className="mr-2 h-4 w-4" />
New Chat
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-neutral-500 dark:text-neutral-400">Loading...</div>
) : (
<div className="space-y-1 p-2">
{chats?.map((chat) => (
<button
key={chat.id}
onClick={() => router.push(`/chat/${chat.id}`)}
className={cn(
"group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left hover:bg-neutral-100 dark:hover:bg-neutral-800",
pathname === `/chat/${chat.id}` && "bg-neutral-100 dark:bg-neutral-800"
)}
>
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{chat.title}</span>
<button
onClick={(e) => handleDeleteChat(chat.id, e)}
className="opacity-0 group-hover:opacity-100 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
</button>
))}
</div>
)}
</div>
</div>
);
}Add Markdown Rendering
pnpm add react-markdown remark-gfm// components/chat/message-content.tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MessageContentProps {
content: string;
}
export function MessageContent({ content }: MessageContentProps) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
return inline ? (
<code className="rounded bg-neutral-200 px-1 py-0.5 dark:bg-neutral-700" {...props}>
{children}
</code>
) : (
<pre className="overflow-x-auto rounded-lg bg-neutral-900 p-4 text-neutral-100">
<code {...props}>{children}</code>
</pre>
);
},
}}
>
{content}
</ReactMarkdown>
);
}Enhancements
Add Typing Indicator
const TypingIndicator = () => (
<div className="flex gap-1">
<span
className="h-2 w-2 animate-bounce rounded-full bg-neutral-400"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-2 w-2 animate-bounce rounded-full bg-neutral-400"
style={{ animationDelay: "150ms" }}
/>
<span
className="h-2 w-2 animate-bounce rounded-full bg-neutral-400"
style={{ animationDelay: "300ms" }}
/>
</div>
);Add Copy Message Button
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
);
};Next Steps
- Custom AI Tools - Add tool calling
- AI Integration - Learn more about AI features
- Caching - Cache chat responses
Last updated on