Building Production AI Chat Applications
This guide covers best practices and patterns for building production-ready AI chat applications using the Vercel AI SDK. We'll cover authentication, persistence, error handling, rate limiting, and UX patterns.
Production Checklist
- Authentication: Secure your API routes
- Persistence: Store conversation history
- Rate Limiting: Prevent abuse and control costs
- Error Handling: Graceful degradation
- Loading States: Better user experience
- Streaming UX: Real-time feedback
Complete Chat Application Structure
app/
├── api/
│ └── chat/
│ └── route.ts # Chat API endpoint
├── chat/
│ ├── page.tsx # Chat page
│ └── [id]/
│ └── page.tsx # Conversation page
├── components/
│ ├── ChatMessages.tsx # Message list component
│ ├── ChatInput.tsx # Input component
│ └── ChatSidebar.tsx # Conversation list
├── lib/
│ ├── db.ts # Database utilities
│ └── rate-limit.ts # Rate limiting
└── types/
└── chat.ts # Type definitions
API Route with Authentication
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { saveMessages, getConversation } from '@/lib/db';
export const maxDuration = 30;
export async function POST(req: Request) {
// Authentication
const session = await auth();
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
// Rate limiting
const rateLimitResult = await rateLimit(session.user.id);
if (!rateLimitResult.success) {
return new Response('Too many requests', {
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
});
}
const { messages, conversationId } = await req.json();
// Validate input
if (!messages || !Array.isArray(messages)) {
return new Response('Invalid messages', { status: 400 });
}
const result = streamText({
model: openai('gpt-4-turbo'),
system: `You are a helpful AI assistant.
User: ${session.user.name}
Be concise and helpful.`,
messages,
onFinish: async ({ text }) => {
// Save to database after completion
await saveMessages(conversationId, session.user.id, [
messages[messages.length - 1],
{ role: 'assistant', content: text },
]);
},
});
return result.toDataStreamResponse();
}
Rate Limiting Implementation
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 requests per minute
analytics: true,
});
export async function rateLimit(userId: string) {
const { success, limit, remaining, reset } = await ratelimit.limit(userId);
return { success, limit, remaining, reset };
}
// Simple in-memory rate limiter (for development)
const requests = new Map<string, number[]>();
export function simpleeRateLimit(userId: string, limit = 20, window = 60000) {
const now = Date.now();
const userRequests = requests.get(userId) || [];
// Filter requests within the window
const recentRequests = userRequests.filter(time => now - time < window);
if (recentRequests.length >= limit) {
return { success: false, remaining: 0 };
}
recentRequests.push(now);
requests.set(userId, recentRequests);
return { success: true, remaining: limit - recentRequests.length };
}
Conversation Persistence
// lib/db.ts (using Prisma example)
import { prisma } from './prisma';
export async function createConversation(userId: string, title?: string) {
return prisma.conversation.create({
data: {
userId,
title: title || 'New Chat',
},
});
}
export async function getConversation(id: string, userId: string) {
return prisma.conversation.findFirst({
where: { id, userId },
include: {
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
}
export async function saveMessages(
conversationId: string,
userId: string,
messages: { role: string; content: string }[]
) {
// Verify ownership
const conversation = await prisma.conversation.findFirst({
where: { id: conversationId, userId },
});
if (!conversation) {
throw new Error('Conversation not found');
}
return prisma.message.createMany({
data: messages.map((msg) => ({
conversationId,
role: msg.role,
content: msg.content,
})),
});
}
export async function getUserConversations(userId: string) {
return prisma.conversation.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
take: 50,
});
}
Chat UI Component
// app/chat/page.tsx
'use client';
import { useChat } from 'ai/react';
import { useRef, useEffect } from 'react';
import { ChatMessages } from '@/components/ChatMessages';
import { ChatInput } from '@/components/ChatInput';
interface ChatPageProps {
conversationId?: string;
initialMessages?: any[];
}
export default function ChatPage({ conversationId, initialMessages }: ChatPageProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error,
reload,
stop,
} = useChat({
id: conversationId,
initialMessages,
body: { conversationId },
onError: (error) => {
console.error('Chat error:', error);
},
});
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="flex flex-col h-screen">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
<ChatMessages messages={messages} isLoading={isLoading} />
<div ref={messagesEndRef} />
</div>
{/* Error display */}
{error && (
<div className="px-4 py-2 bg-red-50 text-red-600 text-sm">
Something went wrong. <button onClick={() => reload()}>Try again</button>
</div>
)}
{/* Input */}
<ChatInput
input={input}
handleInputChange={handleInputChange}
handleSubmit={handleSubmit}
isLoading={isLoading}
onStop={stop}
/>
</div>
);
}
Message List Component
// components/ChatMessages.tsx
import { Message } from 'ai';
import { cn } from '@/lib/utils';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
interface ChatMessagesProps {
messages: Message[];
isLoading: boolean;
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
return (
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[80%] rounded-2xl px-4 py-2',
message.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
)}
>
<ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl px-4 py-2">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
);
}
Chat Input Component
// components/ChatInput.tsx
import { FormEvent, KeyboardEvent } from 'react';
interface ChatInputProps {
input: string;
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
onStop: () => void;
}
export function ChatInput({
input,
handleInputChange,
handleSubmit,
isLoading,
onStop,
}: ChatInputProps) {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const form = e.currentTarget.form;
if (form) form.requestSubmit();
}
};
return (
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 resize-none border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={onStop}
className="px-4 py-3 bg-red-500 text-white rounded-lg"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-4 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Send
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Press Enter to send, Shift+Enter for new line
</p>
</form>
);
}
Error Boundaries
// components/ChatErrorBoundary.tsx
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ChatErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-4 bg-red-50 rounded-lg">
<h2 className="text-red-800 font-semibold">Something went wrong</h2>
<p className="text-red-600 text-sm">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false })}
className="mt-2 px-3 py-1 bg-red-600 text-white rounded text-sm"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
Optimistic Updates
// Using useChat with optimistic updates
const { messages, append, setMessages } = useChat();
// Optimistically add user message before API call
const sendMessage = async (content: string) => {
const userMessage = {
id: crypto.randomUUID(),
role: 'user' as const,
content,
};
// Add user message immediately
setMessages([...messages, userMessage]);
// This will trigger the API call and handle the response
await append(userMessage);
};
Production Tips
- • Use streaming for better perceived performance
- • Implement message virtualization for long conversations
- • Add retry logic for transient failures
- • Consider caching for repeated queries
- • Monitor token usage and costs
- • Add content moderation if user-generated content is involved
Key Takeaways
- • Always authenticate API routes in production
- • Implement rate limiting to control costs and prevent abuse
- • Persist conversations for user experience continuity
- • Handle errors gracefully with retry options
- • Use streaming and loading states for better UX
- • Consider accessibility in your chat interface
Learn More
-
Building Chatbots →
Official guide to building chatbots with AI SDK UI.
-
AI SDK Examples →
Official examples and templates to learn from.