Añadir un asistente de chat a un producto web parece fácil en una demo: llamas a la API de un modelo e imprimes la respuesta. En producción, los usuarios esperan que el texto aparezca a medida que se genera, que el asistente consulte datos en tu sistema, que las respuestas sobrevivan a una recarga de la página y que todo ello no te arruine cuando un bot descubra el endpoint. Esta guía recorre la construcción de un chat de IA en una aplicación Next.js con App Router usando el Vercel AI SDK, desde el primer token en streaming hasta una funcionalidad de producción endurecida. Los mismos patrones sirven si llamas directamente a los SDK de los proveedores.

La arquitectura en una imagen

Browser (React, useChat)
   │  POST /api/chat  (messages)
   ▼
Next.js Route Handler (server)
   ├─ auth + rate limit
   ├─ load context (user, RAG)
   ├─ streamText(model, system, messages, tools)
   │     └─ tools → your DB / APIs (with user's permissions)
   └─ stream response (SSE) ──► Browser renders tokens as they arrive

Principio clave: la clave de API del modelo y toda la ejecución de herramientas se quedan en el servidor. El navegador solo envía mensajes y recibe un stream.

Por qué importa el streaming

Una respuesta típica de 300 tokens tarda varios segundos en generarse. Sin streaming, los usuarios miran un spinner; con streaming, las primeras palabras aparecen en menos de un segundo y la latencia percibida cae drásticamente. El streaming sobre HTTP usa una respuesta de larga duración por fragmentos, normalmente con formato de server-sent events. Los route handlers de Next.js admiten respuestas en streaming de forma nativa mediante la API web ReadableStream (route handlers de Next.js), y el AI SDK gestiona el protocolo por ti.

El streaming también afecta a tu infraestructura: los proxies y balanceadores de carga no deben almacenar en búfer las respuestas, y las funciones serverless necesitan un timeout lo bastante largo para la respuesta completa.

El servidor: un route handler

// app/api/chat/route.ts
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from "ai"
import { anthropic } from "@ai-sdk/anthropic"
import { auth } from "@/lib/auth"
import { rateLimit } from "@/lib/rate-limit"
import { orderTools } from "@/lib/ai/tools"

export const maxDuration = 60 // seconds, for serverless platforms

export async function POST(req: Request) {
  const session = await auth()
  if (!session) return new Response("Unauthorized", { status: 401 })

  const { success } = await rateLimit(`chat:${session.user.id}`, { limit: 30, window: "10m" })
  if (!success) return new Response("Too many requests", { status: 429 })

  const { messages }: { messages: UIMessage[] } = await req.json()

  const result = streamText({
    model: anthropic(process.env.CHAT_MODEL!), // model id from config, not hard-coded
    system: SYSTEM_PROMPT,
    messages: await convertToModelMessages(messages.slice(-20)), // cap history
    tools: orderTools(session.user.id),
    stopWhen: stepCountIs(5), // max tool-call rounds
    maxOutputTokens: 800,
    abortSignal: req.signal, // stop generating if the user navigates away
  })

  return result.toUIMessageStreamResponse()
}

Detalles que importan:

  • Primero la autenticación. Un endpoint de chat sin autenticar es un proxy abierto a una API de pago.
  • Rate limiting por usuario, y global, antes de cualquier llamada al modelo.
  • Límite de historial. Enviar toda la conversación en cada turno hace crecer el coste de forma cuadrática; conserva los últimos N mensajes o resume los antiguos; consulta context engineering.
  • Límite de pasos en los bucles de llamadas a herramientas y máximo de tokens de salida.
  • Señal de aborto para que una pestaña cerrada detenga la generación y la facturación.
  • ID del modelo en la configuración para poder cambiar de modelo sin desplegar; consulta cómo elegir un LLM.

El cliente: useChat

// app/(app)/assistant/chat.tsx
"use client"
import { useChat } from "@ai-sdk/react"
import { useState } from "react"

export function Chat() {
  const { messages, sendMessage, status, stop, error } = useChat()
  const [input, setInput] = useState("")
  const busy = status === "submitted" || status === "streaming"

  return (
    <section aria-label="Assistant">
      <ol aria-live="polite" className="space-y-4">
        {messages.map((m) => (
          <li key={m.id} data-role={m.role}>
            {m.parts.map((part, i) =>
              part.type === "text" ? <p key={i}>{part.text}</p> : null,
            )}
          </li>
        ))}
      </ol>
      {error && <p role="alert">Something went wrong. Please try again.</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (!input.trim() || busy) return
          sendMessage({ text: input })
          setInput("")
        }}
      >
        <label htmlFor="chat-input" className="sr-only">Your question</label>
        <textarea id="chat-input" value={input} onChange={(e) => setInput(e.target.value)} />
        {busy ? (
          <button type="button" onClick={stop}>Stop</button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </section>
  )
}

Los mensajes se componen de partes tipadas —texto, llamadas a herramientas, resultados de herramientas, razonamiento, archivos—, de modo que puedes mostrar la actividad de las herramientas ("Consultando el pedido #4821…") en lugar de dejar a los usuarios esperando en silencio. Un botón de Detener no es opcional; los usuarios necesitan controlar las generaciones largas.

Renderizar la salida del modelo de forma segura

La salida del modelo es una entrada no confiable para tu interfaz. Si renderizas Markdown:

  • Usa un renderizador de Markdown que no permita HTML en bruto, o sanea su salida.
  • No cargues automáticamente imágenes desde URLs proporcionadas por el modelo: es un canal conocido de exfiltración de datos en ataques de prompt injection.
  • Abre los enlaces con rel="noopener noreferrer" y plantéate una lista de dominios permitidos.

Estas defensas forman parte del panorama más amplio de seguridad de agentes de IA y de los riesgos web clásicos del OWASP Top 10.

Herramientas: dejar que el asistente actúe sobre tus datos

Las herramientas son funciones del servidor que el modelo puede llamar. Defínelas con esquemas de Zod e impón los permisos en el código:

// lib/ai/tools.ts
import { tool } from "ai"
import { z } from "zod"
import { db } from "@/lib/db"

export function orderTools(userId: string) {
  return {
    getOrderStatus: tool({
      description:
        "Get status, items and delivery estimate for one of the CURRENT user's orders. " +
        "Use when the user asks where their order is or what it contains.",
      inputSchema: z.object({
        orderNumber: z.string().regex(/^\d{4,8}$/).describe("Order number, digits only"),
      }),
      execute: async ({ orderNumber }) => {
        const order = await db.order.findFirst({
          where: { number: orderNumber, customerId: userId }, // permission check
          select: { number: true, status: true, eta: true, items: { select: { name: true, qty: true } } },
        })
        return order ?? { error: `Order ${orderNumber} not found for this account.` }
      },
    }),
  }
}

Fíjate en que userId viene de la sesión, nunca del modelo. El diseño de las herramientas —nombres, descripciones, salidas compactas, errores accionables— decide lo bien que funciona esto; consulta diseño de herramientas para agentes LLM.

Salida estructurada para funcionalidades de la interfaz

No toda funcionalidad de IA es un chat. Para formularios, filtros o resúmenes que alimentan la interfaz, genera objetos tipados en lugar de texto:

import { generateObject } from "ai"
import { z } from "zod"

const { object } = await generateObject({
  model: anthropic(process.env.FAST_MODEL!),
  schema: z.object({
    category: z.enum(["bug", "billing", "feature_request", "other"]),
    summary: z.string().max(200),
    urgency: z.number().int().min(1).max(3),
  }),
  prompt: `Classify this support message:\n<message>${text}</message>`,
})

streamObject envía objetos parciales en streaming para una interfaz progresiva. El diseño de esquemas y la validación se tratan en salidas estructuradas de LLM.

Persistencia y reanudación

Los usuarios esperan que las conversaciones sobrevivan a las recargas y a los cambios de dispositivo:

  • Guarda los mensajes en el servidor (Postgres funciona bien) por conversación y usuario, incluidas las partes y los resultados de herramientas.
  • Guarda al terminar usando el callback de finalización del SDK, y guarda el mensaje del usuario de inmediato.
  • Carga los mensajes iniciales en un Server Component y pásalos al cliente.
  • Streams reanudables: si la conexión se cae a mitad de respuesta, reanuda desde un búfer en el servidor o regenera. Para la mayoría de los productos basta con mostrar la respuesta parcial con un botón de "Regenerar".

Aplica políticas de retención: los registros de chat contienen datos personales; consulta privacidad en aplicaciones LLM.

Basar las respuestas en tu contenido

Un asistente de producto debe responder a partir de tu documentación, no del conocimiento general del modelo. Añade recuperación antes de la generación: busca en tus documentos con la pregunta del usuario e incluye los mejores fragmentos en el prompt de sistema o como una herramienta que el modelo pueda llamar. El pipeline RAG completo está en RAG para empresas.

Fiabilidad y coste

  • Reintentos y fallbacks: las caídas del proveedor y los 429 ocurren; reintenta con backoff y recurre a otro modelo o proveedor en las funcionalidades críticas; consulta fiabilidad de las APIs de LLM.
  • Prompt caching: mantén el prompt de sistema y las definiciones de herramientas estables y al principio para maximizar los aciertos de caché.
  • Routing de modelos: un modelo rápido y barato para clasificación y respuestas cortas; uno más potente cuando se necesitan herramientas o razonamiento.
  • Presupuestos: límites diarios de tokens por usuario y alertas globales; consulta optimización de costes de LLM.
  • Observabilidad: traza cada petición con tokens, latencia y llamadas a herramientas; consulta observabilidad de LLM.

Accesibilidad y experiencia de usuario

Un chat de IA sigue siendo una interfaz web, y la Ley Europea de Accesibilidad se aplica a muchos productos de consumo:

  • Usa una región aria-live="polite" para los mensajes nuevos, pero evita anunciar cada token: anuncia al terminar o en fragmentos del tamaño de una frase.
  • Asegúrate de que el campo de entrada tenga una etiqueta visible o para lectores de pantalla, y de que Enviar y Detener sean accesibles por teclado.
  • Muestra un estado claro: pensando, usando una herramienta, generando, error.
  • Indica a los usuarios que hablan con una IA y qué puede y qué no puede hacer; es también un requisito de transparencia de la Ley de IA de la UE; consulta guía de la Ley de IA de la UE.

Vigila también el rendimiento: volver a analizar el árbol de Markdown con cada token puede perjudicar el INP. Limita la frecuencia de actualización o memoriza los mensajes completados.

Notas de despliegue

  • Runtime Edge frente a Node: Node es la opción por defecto más segura para el acceso a base de datos y la compatibilidad de SDKs.
  • Búfer: si usas Nginx u otro proxy inverso, desactiva el búfer de respuestas para la ruta del chat; de lo contrario, el streaming llega de golpe.
  • Timeouts: alinea el maxDuration serverless, los timeouts del proxy y el tiempo máximo de generación del modelo.
  • Protección frente a bots: los endpoints de chat atraen abusos; combina autenticación, rate limits y, en chats públicos, un desafío para usuarios anónimos.

Preguntas frecuentes

¿Tenemos que usar el Vercel AI SDK? No. Elimina código repetitivo para streaming, herramientas y estado de React, y admite muchos proveedores. Llamar directamente a los SDK de los proveedores funciona bien; en ese caso implementas tú el protocolo de streaming y el estado del cliente.

¿Podemos usarlo con un modelo self-hosted? Sí. Usa un proveedor compatible con OpenAI apuntando a tu endpoint de vLLM u Ollama; consulta LLM self-hosted.

¿Debería ejecutarse el chat en una Server Action en lugar de en un route handler? Los route handlers encajan mejor con un chat en streaming y son más fáciles de limitar y observar. Las Server Actions funcionan bien para generaciones puntuales ligadas a formularios.

¿Cómo lo probamos? Prueba con tests unitarios las herramientas y las comprobaciones de permisos como cualquier código de servidor, y ejecuta un conjunto de evals con preguntas reales contra todo el endpoint en cada cambio de prompt o de modelo; consulta evals de LLM.

Fuentes

  1. Vercel. AI SDK documentation.
  2. Next.js. Route Handlers (route.js).
  3. MDN. Server-sent events y ReadableStream.
  4. Anthropic. Streaming messages.
  5. MDN. ARIA live regions.
  6. OWASP. Top 10 for LLM Applications 2025.