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.
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.
Hover or tap a component to see what it does.
Show text descriptionHide 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#
- Schema and data access: conversations and messages tables, scoped queries.
- History loading with a token budget.
- The streaming route: validate, limit, load, stream, save.
- The UI: conversation list and chat view, reusing the streaming chat component.
- Polish: titles, error states, empty states, and a usage page.
- Ship: deploy, add a README with screenshots, and record a short demo.
1. Schema#
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)],
);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:
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;
}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:
// 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;
}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:
"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:
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-livefor streamed text. - A usage page that sums
inputTokensandoutputTokensper 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_URLandANTHROPIC_API_KEYas 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
- Build milestones 1 to 4 and deploy them.
- Add the usage page and check its numbers against the Anthropic Console for a day of your own testing.
- Write the README, including one decision you would make differently at 10,000 users.
Check your understanding
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.
Related articles
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.
- Multi-Turn Chat and Conversation Memory with the Claude APIBuild multi-turn chat in Node.js: keep history, replay content blocks correctly, and control cost with sliding windows, summaries, compaction, and long-term memory.
- Databases for AI Apps: PostgreSQL, Drizzle, Redis, and pgvectorDesign an AI app's data layer with PostgreSQL, Drizzle, pgvector for embeddings, and Redis for caching and rate limits, with a full schema and runnable queries.