Add an AI Chat Feature with Claude Code
Build a streaming in-app AI assistant with the Vercel AI SDK: token streaming to the UI, tool calling into your own data, message persistence in Supabase, and per-user usage limits.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
An in-app AI assistant that streams tokens as it thinks, answers from your actual database instead of hallucinating, saves every conversation, and refuses to blow your API budget. That is a weekend feature, not a quarter of engineering. This walkthrough builds all four pieces on Next.js 16 with the Vercel AI SDK and Claude, and shows where Claude Code writes most of the code for you.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
What You Are Building
The feature has four moving parts, and each one maps to a real production concern.
Token streaming means the reply appears word by word instead of after a five second pause. Tool calling lets Claude call functions you write, so it can look up a user's real projects rather than making them up. Persistence stores each message in PostgreSQL so a conversation survives a refresh. Usage limits cap how many messages a user can send per day, which is the difference between a demo and a bill you can predict.
The stack is Next.js 16 App Router, the Vercel AI SDK, Claude via the Anthropic provider, and PostgreSQL via Supabase for storage and row-level security. Install the SDK packages:
npm install ai @ai-sdk/react @ai-sdk/anthropic zodThe ai package holds the server helpers (streamText, tool, the stream response). @ai-sdk/react holds the useChat hook for the client. @ai-sdk/anthropic is the Claude provider. It reads ANTHROPIC_API_KEY from your environment, so add that to .env.local:
ANTHROPIC_API_KEY=sk-ant-your-key-hereThat key bills against your Anthropic API account. It is separate from the Claude Code subscription you might use to write this feature. One pays for the assistant's replies at runtime, the other pays for your coding sessions.
The Database Schema
Two tables carry the conversation: chats (one row per conversation) and messages (one row per message). Both are scoped to a user, and both get row-level security so a user can never read another user's chat. The parts column is JSONB because AI SDK messages are an array of typed parts (text, tool calls, tool results), not a single string.
Run this migration in the Supabase SQL editor:
create table chats (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
title text,
created_at timestamptz not null default now()
);
create table messages (
id uuid primary key default gen_random_uuid(),
chat_id uuid not null references chats(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('user', 'assistant')),
parts jsonb not null default '[]',
created_at timestamptz not null default now()
);
create index messages_chat_id_created_at_idx
on messages (chat_id, created_at);
create index messages_user_created_idx
on messages (user_id, created_at);Now lock both tables down. Every policy checks auth.uid() against the row's user_id, so the database itself enforces isolation even if a query forgets to filter:
alter table chats enable row level security;
alter table messages enable row level security;
create policy "own chats" on chats
for all using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "own messages" on messages
for all using (auth.uid() = user_id)
with check (auth.uid() = user_id);The second index on (user_id, created_at) is not decorative. The usage limit counts a user's messages since midnight, and that query needs an index to stay fast once a table has real traffic.
The Supabase Server Client
Every server-side call needs a Supabase client that reads the user's session from cookies. In Next.js 16, cookies() returns a promise, so the helper is async and you await cookies() inside it. This is the current @supabase/ssr pattern with getAll and setAll:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component where cookies are read-only.
// Safe to ignore when a proxy refreshes the session.
}
},
},
},
);
}The Streaming API Route
This is the heart of the feature. The route handler resolves the user, enforces the usage limit, streams a Claude response, and persists the result. Read it top to bottom, then I will break down the four sections.
The AI SDK works in two message shapes. UIMessage is the rich client shape with parts. convertToModelMessages flattens that into what the model actually consumes. streamText returns a result you hand straight back as a streaming HTTP response with toUIMessageStreamResponse, and its onFinish callback fires once the full reply is assembled, which is where persistence belongs.
// app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic";
import {
streamText,
convertToModelMessages,
stepCountIs,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
const DAILY_MESSAGE_LIMIT = 50;
export async function POST(req: Request) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
// Usage limit: count this user's messages since midnight.
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const { count } = await supabase
.from("messages")
.select("id", { count: "exact", head: true })
.eq("user_id", user.id)
.eq("role", "user")
.gte("created_at", startOfDay.toISOString());
if ((count ?? 0) >= DAILY_MESSAGE_LIMIT) {
return new Response("Daily message limit reached", { status: 429 });
}
const { messages, chatId }: { messages: UIMessage[]; chatId: string } =
await req.json();
// Persist the incoming user message before we spend a token.
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "user") {
await supabase.from("messages").insert({
chat_id: chatId,
user_id: user.id,
role: "user",
parts: lastMessage.parts,
});
}
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
system:
"You are an assistant inside a SaaS app. When the user asks about " +
"their own projects or data, call searchProjects. Never invent data.",
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools: {
searchProjects: tool({
description:
"Search the current user's projects by keyword. Use when the " +
"user asks about their own projects, tasks, or status.",
inputSchema: z.object({
query: z
.string()
.describe("Keywords to match against project names"),
}),
execute: async ({ query }) => {
const { data } = await supabase
.from("projects")
.select("id, name, status, description")
.eq("user_id", user.id)
.ilike("name", `%${query}%`)
.limit(5);
return { projects: data ?? [] };
},
}),
},
});
return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: async ({ responseMessage }) => {
await supabase.from("messages").insert({
chat_id: chatId,
user_id: user.id,
role: "assistant",
parts: responseMessage.parts,
});
},
});
}Four things are happening. First, supabase.auth.getUser() gives you the signed-in user or nothing. Second, the count query enforces the daily cap before any paid work runs. Third, streamText calls Claude with your tools attached. Fourth, onFinish writes the assistant reply to the database once it is complete.
The order matters. The usage check sits above streamText on purpose. A user who is over the limit gets a 429 and never triggers a model call, so the guard actually protects your budget instead of just hiding the reply after you have already paid for it.
Tool Calling Into Your Own Data
The searchProjects tool is what turns a generic chatbot into an assistant that knows your product. A tool is three things: a description that tells Claude when to reach for it, an inputSchema written in Zod that defines and validates the arguments, and an execute function that runs on your server.
Because execute is defined inside the route handler, it closes over the authenticated supabase client and the user. So the query is scoped to user.id, and row-level security scopes it again at the database. There is no path for one user's tool call to read another user's projects.
The flow is worth spelling out. The user asks "what's the status of my launch project?" Claude sees the searchProjects tool, decides it needs it, and emits a tool call with { query: "launch" }. The SDK validates those arguments against your Zod schema, runs execute, and hands the returned rows back to Claude. Claude then writes a natural-language answer grounded in the real rows. stopWhen: stepCountIs(5) caps that loop at five steps, so even a confused model cannot spiral into an endless chain of tool calls on a single turn.
This is the pattern for any read into your own system: a Zod schema for the inputs, a scoped query in execute, and a description precise enough that Claude knows exactly when to use it.
Streaming to the UI
The client is a Client Component built around the useChat hook. It takes initial messages loaded from the database and a transport that points at your route. Streaming, request state, and message assembly are all handled for you. You supply the input box and decide how to render each message part.
Messages are not plain strings. Each message has a parts array, and each part has a type. Text parts render as text. Tool parts (named tool-searchProjects after your tool) let you show that the assistant is looking something up. The status value drives the disabled state on the input so a user cannot fire a second request mid-stream:
// app/chat/[id]/chat.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useState } from "react";
export function Chat({
chatId,
initialMessages,
}: {
chatId: string;
initialMessages: UIMessage[];
}) {
const [input, setInput] = useState("");
const { messages, sendMessage, status, error } = useChat({
id: chatId,
messages: initialMessages,
transport: new DefaultChatTransport({
api: "/api/chat",
body: { chatId },
}),
});
const isBusy = status === "submitted" || status === "streaming";
return (
<div className="mx-auto flex h-screen max-w-2xl flex-col p-4">
<div className="flex-1 space-y-4 overflow-y-auto">
{messages.map((message) => (
<div
key={message.id}
className={message.role === "user" ? "text-right" : "text-left"}
>
<div className="inline-block rounded-lg bg-muted px-3 py-2">
{message.parts.map((part, i) => {
if (part.type === "text") {
return <span key={i}>{part.text}</span>;
}
if (part.type === "tool-searchProjects") {
return (
<span key={i} className="text-xs opacity-60">
Searching your projects...
</span>
);
}
return null;
})}
</div>
</div>
))}
</div>
{error ? (
<p className="py-2 text-sm text-red-500">
{error.message.includes("limit")
? "You have hit your daily message limit. Try again tomorrow."
: "Something went wrong. Please retry."}
</p>
) : null}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim() || isBusy) return;
sendMessage({ text: input });
setInput("");
}}
className="flex gap-2 pt-4"
>
<input
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Ask about your projects..."
disabled={isBusy}
className="flex-1 rounded-md border px-3 py-2"
/>
<button
type="submit"
disabled={isBusy}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
Send
</button>
</form>
</div>
);
}sendMessage({ text: input }) posts the new message plus the running history to your route. DefaultChatTransport merges the body you pass (the chatId) into every request, which is how the server knows which conversation to append to.
Loading a Conversation on the Server
The page is a Server Component. It awaits the route params (required in Next.js 16), loads the conversation's messages from Supabase, and passes them to the client. Row-level security means the query only returns rows the signed-in user owns, so an attacker cannot load someone else's chat by guessing an id.
// app/chat/[id]/page.tsx
import { createClient } from "@/lib/supabase/server";
import { Chat } from "./chat";
import type { UIMessage } from "ai";
export default async function ChatPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const supabase = await createClient();
const { data } = await supabase
.from("messages")
.select("id, role, parts")
.eq("chat_id", id)
.order("created_at", { ascending: true });
const initialMessages = (data ?? []) as UIMessage[];
return <Chat chatId={id} initialMessages={initialMessages} />;
}Because the parts column stores the exact AI SDK part array, the messages come back in the shape useChat expects. No transformation, no lossy conversion between a database row and a UI message.
Gating the Route With proxy.ts
The API route already rejects unauthenticated requests, but you also want to keep signed-out users off the /chat pages and keep the Supabase session token fresh. In Next.js 16 that logic lives in proxy.ts at the project root, the replacement for the old middleware.ts. It refreshes the session on every matched request and redirects visitors with no session to your login page:
// proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
matcher: ["/chat/:path*"],
};The matcher limits this to /chat routes so your marketing pages and login flow stay public. Keeping the redirect here means the chat page never renders a flash of protected content before bouncing an unauthenticated user.
Per-User Usage Limits, Reconsidered
The daily count in the route is the simplest cap that actually works, and it is enough to keep a free tier honest. Two upgrades are worth knowing about as you grow.
Count tokens, not just messages. A message limit is coarse: one long conversation can cost more than fifty short ones. streamText reports token usage in its onFinish callback, so you can add a usage column and accumulate real consumption per user. Then your cap becomes a token budget, which lines up with what Anthropic actually bills you.
Tier the limit by plan. Read the user's plan from your profiles or Stripe subscription table and pick the cap from that instead of a hard-coded constant. Free users get 50 messages a day, paid users get more or an unmetered experience. The check stays in the same place, above streamText, so the guard keeps protecting your margin no matter which tier the user is on.
Whatever you count, enforce it before the model call. A limit that runs after you have already streamed a reply protects nothing.
Building It With Claude Code
Every file here is something Claude Code writes well when you give it the shape. Point it at the AI SDK by adding a line to CLAUDE.md so it uses the current API instead of an older streaming pattern from its training data:
## AI Chat
- Use the Vercel AI SDK: streamText on the server, useChat on the client.
- Messages are UIMessage[] with a parts array. Persist parts as JSONB.
- Enforce the per-user daily limit in the route BEFORE calling streamText.
- Model: anthropic("claude-sonnet-4-5") from @ai-sdk/anthropic.Then work in plan mode before the build. Ask Claude to lay out the migration, the route, the client, and the proxy, review the plan, and only then let it write. The usage limit is exactly the kind of detail that is easy to forget and expensive to miss, so calling it out in CLAUDE.md makes Claude place the guard correctly every time.
The $29 Code Kit ($29 one-time, no subscription) is the harness that wraps this loop around Claude Code so you do not have to run it by hand: it plans the feature, builds it, evaluates the output, tests it against a real browser, and runs the quality gates before anything ships, meaning zero type errors, zero lint errors, and a clean Turbopack build. Claude Code still needs its own paid Anthropic plan to run, and this key streaming feature bills separately at API rates. What the Kit adds is the discipline around the model, so a chat feature like this one lands finished instead of almost-working.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
Posted by @speedy_devv
Stop configuring. Start building.
SaaS builder templates with AI orchestration.