Build This Now
Build This Now
Was ist der Claude Code?Claude Code installierenClaude Code Native InstallerDein erstes Claude Code-Projekt
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: 1.000 Subagents auf einer echten Codebase orchestrierenClaude Code Best PracticesClaude Opus 4.7 Best PracticesClaude Code auf einem VPSGit-IntegrationClaude Code ReviewClaude Code WorktreesClaude Code Remote ControlClaude Code ChannelsChannels, Routines, Teleport, DispatchGeplante Aufgaben mit Claude CodeClaude Code BerechtigungenClaude Code Auto-ModusStripe-Zahlungen mit Claude Code einbauenFeedback-LoopsTodo-WorkflowsClaude Code TasksProjekt-TemplatesClaude Code Preise und Token-NutzungClaude Code Preise: Was du wirklich zahlstClaude Code Ultra ReviewEine Next.js-App mit Claude Code bauenClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeSo baust du ein SaaS-MVP mit Claude CodeAuthentifizierung mit Claude Code einbauen (Supabase Auth)Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)Eine typsichere API mit Claude Code bauen (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen könnenClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt CachingWas es 2026 kostet, ein SaaS mit Claude Code zu bauenRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/AI Chat Feature

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.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvWritten by speedy_devvPublished Jul 14, 202611 min readHandbook hubWorkflow index

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.


Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

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 zod

The 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-here

That 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.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können
    Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.
  • Claude Code Best Practices
    Fünf Gewohnheiten trennen Entwickler, die mit Claude Code liefern: PRDs, modulare CLAUDE.md-Regeln, Custom-Slash-Commands, /clear-Resets und eine System-Evolutions-Denkweise.
  • Claude Code Auto-Modus
    Ein zweites Sonnet-Modell prüft jeden Claude Code-Tool-Aufruf, bevor er ausgeführt wird. Was der Auto-Modus blockiert, was er erlaubt, und die Erlaubnisregeln, die er in deine Einstellungen schreibt.
  • Channels, Routines, Teleport, Dispatch
    Die vier Claude-Code-Features, die Anthropic im März und April 2026 ausgeliefert hat und die die CLI in eine ereignisgesteuerte Koordinationsschicht über Handy, Web und Desktop verwandeln.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Grundlagen für Agenten
    Fünf Möglichkeiten, spezialisierte Agenten in Claude Code zu erstellen: Aufgaben-Unteragenten, .claude/agents YAML, benutzerdefinierte Slash-Befehle, CLAUDE.md Personas und perspektivische Aufforderungen.
  • Agent-Harness-Engineering
    Der Harness ist jede Schicht rund um deinen KI-Agenten, außer dem Modell selbst. Lern die fünf Steuerungshebel, das Constraint-Paradoxon und warum das Harness-Design die Performance des Agenten mehr bestimmt als das Modell.
  • Agenten-Muster
    Orchestrator, Fan-out, Validierungskette, Spezialistenrouting, Progressive Verfeinerung und Watchdog. Sechs Orchestrierungsformen, um Claude Code Sub-Agenten zu verdrahten.
  • Agent Teams Best Practices
    Bewährte Muster für Claude Code Agent Teams. Kontextreiche Spawn-Prompts, richtig bemessene Aufgaben, Datei-Eigentümerschaft, Delegate-Modus und Fixes für v2.1.33-v2.1.45.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

Multi-Tenant SaaS

Add organizations, seats, and invite flows to a Next.js SaaS: workspace-scoped tables, Supabase RLS for tenant isolation, team invites via Resend, and a member and role switcher.

Rate Limiting

Protect your oRPC procedures and Next.js 16 API routes from abuse and runaway costs with Upstash Redis sliding-window limits, per-user and per-IP keys, clean 429 responses, and graceful client retries.

On this page

What You Are Building
The Database Schema
The Supabase Server Client
The Streaming API Route
Tool Calling Into Your Own Data
Streaming to the UI
Loading a Conversation on the Server
Gating the Route With proxy.ts
Per-User Usage Limits, Reconsidered
Building It With Claude Code

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →