TechLead
Lesson 8 of 8
7 min read
AI SDK

Building AI Chat Applications

Build production-ready AI chat applications with best practices and patterns

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

Continue Learning