Construir aplicaciones de chat con IA en producción
Esta guía cubre mejores prácticas y patrones para construir aplicaciones de chat con IA listas para producción usando Vercel AI SDK. Veremos autenticación, persistencia, manejo de errores, rate limiting y patrones de UX.
Checklist de producción
- Autenticación: Protege tus rutas API
- Persistencia: Almacena el historial de conversación
- Rate limiting: Evita abuso y controla costos
- Manejo de errores: Degradación elegante
- Estados de carga: Mejor experiencia de usuario
- UX de streaming: Feedback en tiempo real
Estructura completa de la app de chat
Una aplicación de chat bien organizada separa la lógica API, los componentes de UI, las utilidades de base de datos y los tipos en directorios claros dentro del App Router de Next.js.
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
Ruta API con autenticación
En producción, la ruta API debe verificar la identidad del usuario, aplicar límites de uso y guardar los mensajes en base de datos tras completar la respuesta.
// 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();
}
Implementación de rate limiting
El rate limiting protege tu API contra abuso y controla costos. Puedes usar servicios como Upstash Redis para límites distribuidos o un enfoque en memoria para el desarrollo local.
// 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 };
}
Persistencia de conversaciones
Almacenar las conversaciones en una base de datos permite a los usuarios retomar chats previos. Con Prisma u otro ORM, puedes crear, recuperar y actualizar conversaciones vinculadas a cada usuario.
// 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,
});
}
Componente de UI de chat
El componente principal del chat conecta el hook useChat con los subcomponentes de mensajes e input, gestionando auto-scroll, errores y estados de carga.
// 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>
);
}
Componente de lista de mensajes
Este componente renderiza la lista de mensajes con estilos diferenciados para el usuario y la IA. Soporta Markdown y resaltado de código para respuestas ricas.
// 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>
);
}
Componente de entrada de chat
El componente de entrada maneja el envío de mensajes por botón o con la tecla Enter, e incluye un botón para detener la generación en curso.
// 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;
}
}
Actualizaciones optimistas
// 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);
};
Consejos de producción
- • Usa streaming para mejor rendimiento percibido
- • Implementa virtualización de mensajes para conversaciones largas
- • Añade lógica de reintento para fallos transitorios
- • Considera caché para consultas repetidas
- • Monitorea uso de tokens y costos
- • Añade moderación de contenido si hay contenido generado por usuarios
💡 Puntos clave
- • Autentica siempre las rutas API en producción
- • Implementa rate limiting para controlar costos y abuso
- • Persiste conversaciones para continuidad de experiencia
- • Maneja errores con opciones de reintento
- • Usa streaming y estados de carga para mejor UX
- • Considera accesibilidad en la interfaz de chat
📚 Más recursos
-
Crear chatbots →
Guía oficial para construir chatbots con AI SDK UI.
-
Ejemplos de AI SDK →
Ejemplos y plantillas oficiales para aprender.