Skip to content
AI Full-Stack Wiki

Project: Build a Full-Stack AI Chat App with Next.js and Claude

Build a production-quality AI chat app: streaming replies, saved conversations in Postgres, history trimming, rate limits, usage tracking, and automatic titles.

Difficulty: Intermediate22 min readUpdated
On this page· What you will build

Why this matters

A chat app is the "hello world" of AI products, and almost every hiring manager has seen a weak one: a text box that calls an API from the browser, forgets everything on refresh, and breaks on the first error. A strong one shows you can build the real thing: streaming, persistence, per-user data isolation, limits, cost tracking, and graceful failure. It is also the foundation the later projects build on.

What you will build#

  • Sign-in (with any auth provider) and a list of the user's conversations.
  • A chat view that streams replies, with stop and retry.
  • Conversations and messages saved in Postgres, with token usage per reply.
  • History trimming so long conversations stay within budget.
  • Rate limits per user, and automatic titles for new conversations.
AI chat app architectureThe browser renders a chat UI that posts messages to a Next.js route handler and reads the streamed reply. The route checks the session and rate limit, loads history from Postgres, streams from the Claude API, and saves the reply with its token usage.SSE streamUserChat UIReact clientRoute handlerPOST /api/chatAuth + limitssession, budgetPostgresmessages, usageClaude API

Hover or tap a component to see what it does.

AI chat app architecture: a React chat UI streams replies from a Next.js route handler, which checks auth and limits, loads and saves history in Postgres, and streams from the Claude API.
Show text description

Everything sensitive happens on the server: the API key, limits, and access to stored conversations.

  • User
  • Chat UI: Sends the message, renders streamed text, handles stop and retry, and shows errors without losing input.
  • Route handler: Validates input, checks auth and limits, assembles context from history, streams the reply as server-sent events, and saves it.
  • Auth + limits: Who is asking, and are they within their rate limit and daily budget?
  • Postgres: Conversations and messages per user, with token usage per reply for cost tracking.
  • Claude API: Streams the reply. Prompt caching keeps long conversations affordable.

Stack: Next.js App Router, TypeScript, Postgres with Drizzle ORM, the Anthropic SDK, Zod. This project assumes you have worked through streaming to React and multi-turn chat; it reuses their pieces.

Milestones#

  1. Schema and data access: conversations and messages tables, scoped queries.
  2. History loading with a token budget.
  3. The streaming route: validate, limit, load, stream, save.
  4. The UI: conversation list and chat view, reusing the streaming chat component.
  5. Polish: titles, error states, empty states, and a usage page.
  6. Ship: deploy, add a README with screenshots, and record a short demo.

1. Schema#

db/schema.ts
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
 
export const conversations = pgTable(
  "conversations",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: text("user_id").notNull(),
    title: text("title").notNull().default("New chat"),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [index("conversations_user_idx").on(t.userId, t.updatedAt)],
);
 
export const messages = pgTable(
  "messages",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    conversationId: uuid("conversation_id")
      .notNull()
      .references(() => conversations.id, { onDelete: "cascade" }),
    role: text("role", { enum: ["user", "assistant"] }).notNull(),
    text: text("text").notNull(),
    inputTokens: integer("input_tokens"),
    outputTokens: integer("output_tokens"),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [index("messages_conversation_idx").on(t.conversationId, t.createdAt)],
);
db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
 
export const db = drizzle({ client: new Pool({ connectionString: process.env.DATABASE_URL }), schema });

Messages store plain text here, which is all a chat app needs. If you add tools later, store the full content blocks as jsonb instead, so assistant turns can be replayed exactly.

2. History with a token budget#

Long conversations grow without bound. Load recent messages up to a budget, and always start with a user message, as the API requires:

lib/history.ts
export interface StoredMessage {
  role: "user" | "assistant";
  text: string;
}
 
/** Rough token estimate for budgeting (about 4 characters per token in English). Use countTokens for exact numbers. */
export const estimateTokens = (text: string) => Math.ceil(text.length / 4);
 
/**
 * Keep the most recent messages that fit in `budget` tokens. The result starts with a user message
 * and alternates roles, as the Messages API expects.
 */
export function trimHistory(history: StoredMessage[], budget: number): StoredMessage[] {
  const kept: StoredMessage[] = [];
  let used = 0;
  for (let i = history.length - 1; i >= 0; i--) {
    const cost = estimateTokens(history[i]!.text);
    if (used + cost > budget && kept.length > 0) break;
    kept.unshift(history[i]!);
    used += cost;
  }
  while (kept.length > 0 && kept[0]!.role !== "user") kept.shift();
  return kept;
}
history.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { trimHistory, type StoredMessage } from "./lib/history.ts";
 
const msg = (role: StoredMessage["role"], chars: number): StoredMessage => ({ role, text: "x".repeat(chars) });
 
test("keeps everything when it fits", () => {
  const h = [msg("user", 40), msg("assistant", 40), msg("user", 40)];
  assert.equal(trimHistory(h, 1000).length, 3);
});
 
test("drops the oldest messages first and starts with a user turn", () => {
  const h = [msg("user", 400), msg("assistant", 400), msg("user", 400), msg("assistant", 400), msg("user", 40)];
  const kept = trimHistory(h, 230); // 100 + 100 + 10 tokens fit; the oldest pair does not
  assert.deepEqual(kept.map((m) => m.role), ["user", "assistant", "user"]);
});
 
test("always keeps the newest message, even if it alone exceeds the budget", () => {
  const kept = trimHistory([msg("user", 8000)], 100);
  assert.equal(kept.length, 1);
});

3. The streaming route#

The route ties everything together. Every query includes the user ID, so one user can never read or write another's conversation:

lib/session.ts
// Replace with your auth provider's session lookup (Auth.js, Clerk, Supabase Auth, or your own).
// This demo version trusts a header, which is only acceptable for local development.
export async function getUserId(headers: Headers): Promise<string | null> {
  return headers.get("x-demo-user") ?? null;
}
app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { and, asc, eq, gt, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { conversations, messages } from "@/db/schema";
import { trimHistory } from "@/lib/history";
import { getUserId } from "@/lib/session";
 
const anthropic = new Anthropic();
const Body = z.object({ conversationId: z.uuid().optional(), message: z.string().trim().min(1).max(8000) });
const MESSAGES_PER_MINUTE = 10;
const HISTORY_BUDGET = 30_000;
 
const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
 
export async function POST(request: Request) {
  const userId = await getUserId(request.headers);
  if (!userId) return Response.json({ error: "Sign in to chat" }, { status: 401 });
 
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });
  const { message } = parsed.data;
 
  // Rate limit: user messages in the last minute, across all of this user's conversations.
  const [{ count }] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(messages)
    .innerJoin(conversations, eq(messages.conversationId, conversations.id))
    .where(and(eq(conversations.userId, userId), eq(messages.role, "user"), gt(messages.createdAt, sql`now() - interval '1 minute'`)));
  if (count >= MESSAGES_PER_MINUTE) {
    return Response.json({ error: "You're sending messages quickly. Try again in a minute." }, { status: 429, headers: { "retry-after": "60" } });
  }
 
  // Load or create the conversation, always scoped to this user.
  let conversationId = parsed.data.conversationId;
  if (conversationId) {
    const owned = await db.query.conversations.findFirst({ where: and(eq(conversations.id, conversationId), eq(conversations.userId, userId)) });
    if (!owned) return Response.json({ error: "Conversation not found" }, { status: 404 });
  } else {
    const [created] = await db.insert(conversations).values({ userId }).returning({ id: conversations.id });
    conversationId = created!.id;
  }
 
  await db.insert(messages).values({ conversationId, role: "user", text: message });
  const stored = await db
    .select({ role: messages.role, text: messages.text })
    .from(messages)
    .where(eq(messages.conversationId, conversationId))
    .orderBy(asc(messages.createdAt));
  const history = trimHistory(stored, HISTORY_BUDGET);
 
  const isNew = !parsed.data.conversationId;
  const id = conversationId;
 
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const encoder = new TextEncoder();
      const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(sse(event, data)));
      send("conversation", { id });
      try {
        const stream = anthropic.messages.stream(
          {
            model: "claude-opus-5-5",
            max_tokens: 4096,
            system: "You are a helpful, concise assistant. Use Markdown for code and lists.",
            cache_control: { type: "ephemeral" },
            messages: history.map((m) => ({ role: m.role, content: m.text })),
          },
          { signal: request.signal },
        );
        stream.on("text", (text) => send("text", { text }));
        const final = await stream.finalMessage();
        const reply = final.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
 
        await db.insert(messages).values({
          conversationId: id,
          role: "assistant",
          text: reply,
          inputTokens: final.usage.input_tokens + (final.usage.cache_read_input_tokens ?? 0) + (final.usage.cache_creation_input_tokens ?? 0),
          outputTokens: final.usage.output_tokens,
        });
        await db.update(conversations).set({ updatedAt: new Date() }).where(eq(conversations.id, id));
        send("done", { stopReason: final.stop_reason });
 
        if (isNew) void nameConversation(id, message); // runs after the reply; failures are harmless
      } catch (error) {
        if (!request.signal.aborted) {
          console.error("chat stream failed", error);
          send("error", { message: "The reply was interrupted. Please try again." });
        }
      } finally {
        try {
          controller.close();
        } catch {
          // already closed because the client disconnected
        }
      }
    },
  });
 
  return new Response(body, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform" } });
}
 
// A small, fast model names new conversations from the first message.
async function nameConversation(conversationId: string, firstMessage: string) {
  try {
    const res = await anthropic.messages.create({
      model: "claude-haiku-4-5",
      max_tokens: 30,
      system: "Write a title of at most 6 words for a chat that starts with this message. Reply with the title only, no quotes.",
      messages: [{ role: "user", content: firstMessage.slice(0, 2000) }],
    });
    const title = res.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("").trim().slice(0, 80);
    if (title) await db.update(conversations).set({ title }).where(eq(conversations.id, conversationId));
  } catch (error) {
    console.warn("title generation failed", error);
  }
}

The response adds a conversation event before the text, so the client learns the ID of a newly created conversation and can update the URL.

4. The UI#

The chat component is the one from the streaming article, adapted to send a conversationId and to learn the ID of a new conversation from the conversation event:

components/chat.tsx
"use client";
 
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
 
type Message = { role: "user" | "assistant"; text: string };
type Status = "idle" | "streaming" | "error";
 
async function* readEvents(body: ReadableStream<Uint8Array>) {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return;
    buffer += decoder.decode(value, { stream: true });
    let end: number;
    while ((end = buffer.indexOf("\n\n")) !== -1) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const event = /^event: (.+)$/m.exec(block)?.[1];
      const data = /^data: (.+)$/m.exec(block)?.[1];
      if (event && data) yield { event, data: JSON.parse(data) as Record<string, string> };
    }
  }
}
 
export function Chat({ conversationId, initialMessages }: { conversationId?: string; initialMessages: Message[] }) {
  const router = useRouter();
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [input, setInput] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);
  const idRef = useRef(conversationId);
  const abortRef = useRef<AbortController | null>(null);
 
  async function send(text: string) {
    setError(null);
    setStatus("streaming");
    setMessages((m) => [...m, { role: "user", text }, { role: "assistant", text: "" }]);
    const abort = new AbortController();
    abortRef.current = abort;
    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ conversationId: idRef.current, message: text }),
        signal: abort.signal,
      });
      if (!res.ok || !res.body) throw new Error(((await res.json().catch(() => null)) as { error?: string } | null)?.error ?? "Request failed");
      for await (const { event, data } of readEvents(res.body)) {
        if (event === "conversation" && !idRef.current) {
          idRef.current = data.id;
          router.replace(`/chat/${data.id}`, { scroll: false });
        } else if (event === "text") {
          setMessages((m) => [...m.slice(0, -1), { role: "assistant", text: m.at(-1)!.text + data.text }]);
        } else if (event === "error") {
          throw new Error(data.message);
        }
      }
      setStatus("idle");
    } catch (e) {
      if (abort.signal.aborted) return setStatus("idle"); // the user pressed Stop; keep the partial reply
      setError(e instanceof Error ? e.message : "Something went wrong");
      setStatus("error");
    }
  }
 
  return (
    <div className="flex flex-col gap-4">
      <div role="log" aria-live="polite" aria-busy={status === "streaming"} className="flex flex-col gap-3">
        {messages.map((m, i) => (
          <p key={i} className={m.role === "user" ? "self-end rounded-lg bg-muted px-3 py-2" : "whitespace-pre-wrap"}>
            {m.text}
          </p>
        ))}
      </div>
      {error ? <p role="alert">{error}</p> : null}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const text = input.trim();
          if (!text || status === "streaming") return;
          setInput("");
          void send(text);
        }}
        className="flex gap-2"
      >
        <textarea value={input} onChange={(e) => setInput(e.target.value)} aria-label="Message" className="flex-1 rounded-md border p-2" rows={2} />
        {status === "streaming" ? (
          <button type="button" onClick={() => abortRef.current?.abort()}>Stop</button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </div>
  );
}

The conversation page loads the history on the server, scoped to the user, and hands it to the client component:

app/chat/[id]/page.tsx
import { and, asc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Chat } from "@/components/chat";
import { db } from "@/db";
import { conversations, messages } from "@/db/schema";
import { getUserId } from "@/lib/session";
 
export default async function ConversationPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const userId = await getUserId(await headers());
  if (!userId) redirect("/sign-in");
 
  const conversation = await db.query.conversations.findFirst({ where: and(eq(conversations.id, id), eq(conversations.userId, userId)) });
  if (!conversation) notFound();
 
  const history = await db
    .select({ role: messages.role, text: messages.text })
    .from(messages)
    .where(eq(messages.conversationId, id))
    .orderBy(asc(messages.createdAt));
 
  return (
    <main className="mx-auto max-w-3xl p-4">
      <h1 className="mb-4 text-lg font-semibold">{conversation.title}</h1>
      <Chat conversationId={id} initialMessages={history} />
    </main>
  );
}

app/chat/page.tsx renders <Chat initialMessages={[]} /> for a new conversation, and a sidebar lists the user's conversations ordered by updatedAt.

5. Polish that makes it portfolio-ready#

  • States: an empty state with example prompts, a loading skeleton, a clear error with retry, and a notice when the rate limit is hit.
  • Markdown rendering for replies, with code highlighting and links sanitized (see security).
  • Keyboard and accessibility: Enter to send, Shift+Enter for a newline, focus management, aria-live for streamed text.
  • A usage page that sums inputTokens and outputTokens per day for the signed-in user, converted to an approximate cost.
  • Delete and rename conversations.

6. Ship it#

  • Deploy to a platform that supports streaming responses (most Node.js hosts and Vercel do); set DATABASE_URL and ANTHROPIC_API_KEY as secrets.
  • Run migrations with Drizzle Kit as part of the deploy.
  • Write a README with a screenshot or short video, the architecture diagram, how to run it locally, and the decisions you made (why trimming, why the rate limit values).

Stretch goals#

  • Prompt caching statistics on the usage page (cache reads versus writes).
  • Summarize old history instead of dropping it (multi-turn chat).
  • File uploads (PDFs and images) as message content.
  • A share link for a read-only copy of a conversation.

Common mistakes#

  • Calling the API from the browser. The key leaks. All model calls go through your server.
  • Queries without the user ID. Every read and write of a conversation must check ownership.
  • Saving the reply only on success. Decide what to store when the user stops a stream; at minimum, do not leave the conversation in a broken state.
  • Unbounded history. Every turn resends everything; trim or summarize.
  • No limits. One script can send thousands of messages. Rate-limit per user.

Try it

Make it yours

  1. Build milestones 1 to 4 and deploy them.
  2. Add the usage page and check its numbers against the Anthropic Console for a day of your own testing.
  3. Write the README, including one decision you would make differently at 10,000 users.

Check your understanding

Why does every conversation query include the user ID?
Why must trimmed history start with a user message?
Why generate titles with a small model after the reply?

Key takeaways

  • A portfolio-grade chat app streams, persists, isolates users, limits usage, and tracks cost.
  • Scope every query by user, trim history to a budget, and keep the API key on the server.
  • Reuse building blocks from earlier articles, and polish the states users actually see.
  • Ship it with a README that explains your decisions.

Next steps#

Finished this article? Track it to see your progress on the roadmap.