Una UI de chat que espera la respuesta completa se siente trabada; una que va pintando los tokens a medida que llegan se siente viva. Esta guía arma todo el flujo en el App Router de Next.js: un route handler que devuelve un ReadableStream, el formato SSE que aguanta el paso por los proxies, un cliente que lee el stream y va pintando los deltas, y los detalles de producción (buffering, cancelación, qué runtime elegir) que matan el streaming sin hacer ruido, cuando nadie está mirando.

En resumen
- Devuelve un ReadableStream desde el route handler y encola los chunks codificados a medida que llega cada delta del modelo, en el App Router no necesitas ninguna librería extra.
- Decide el formato a conciencia: texto plano es lo que menos código pide; SSE de verdad (text/event-stream con líneas data:) te suma eventos con nombre, ids y reconexión automática.
- Cierra siempre el controller cuando el modelo termina, y conecta el abort signal del request para que el usuario que se va no te siga gastando tokens.
- Los proxies que bufferizan son el enemigo número uno, y de los silenciosos: confirma que Traefik/nginx no estén acumulando la respuesta, y nunca envuelvas el stream en nada que primero lo junte todo en un solo string.
- En el cliente, lee con response.body.getReader() (o EventSource si vas con SSE puro) y ve agregando los deltas decodificados; acumula unos cuantos antes de cada escritura al DOM.
Esperar diez segundos por un muro de texto se siente como si la app se hubiera colgado. Transmitir esa misma respuesta token por token se siente instantáneo, aunque el tiempo total sea idéntico. Esta guía arma el streaming de extremo a extremo en el App Router de Next.js: un route handler que devuelve un ReadableStream conectado al SDK de Claude, el formato de Server-Sent Events que aguanta el paso por los reverse proxies, un cliente en el navegador que lee el stream y va pintando los deltas, y el puñado de fallas de producción, buffering, requests que quedan colgados, el runtime equivocado, que dejan el streaming roto de maneras que nunca vas a ver en localhost.
01 · Requisitos y el modelo mental
Antes de escribir una línea de código, dos cosas tienen que estar dadas y una tiene que quedarte clara.
Importante
Necesitas Next.js 14+ con el App Router (una ruta en app/api/chat/route.ts), Node 18+, y la clave del modelo en el entorno como ANTHROPIC_API_KEY, con el SDK instalado vía npm install @anthropic-ai/sdk. La clave vive solo en el servidor. Un route handler es justo el lugar para ella: el navegador le habla a tu ruta, tu ruta le habla a Anthropic, y la clave nunca llega al cliente, donde cualquiera podría leerla y ponerse a gastarla.
El modelo mental es la parte que la gente se salta, y por eso terminan con el stream roto. Una respuesta HTTP normal es un solo body: el servidor calcula todo, le pone un content-length y lo manda. El streaming le da la vuelta a eso. El servidor manda un body de longitud desconocida, por partes, sobre una conexión que mantiene abierta. Tres cosas tienen que jugar juntas para que el usuario vea llegar los tokens:
- El route handler genera los chunks de a poco y va soltando cada uno, en vez de bufferizar la respuesta completa.
- Nada en el camino entre el handler y el navegador, ni Next.js, ni un reverse proxy, ni un CDN, junta esos chunks en una sola respuesta antes de reenviarla.
- El cliente lee el body como un stream en lugar de llamar a response.text(), que espera hasta el final.
Si se rompe cualquiera de los tres, el usuario no ve nada hasta que el modelo termina por completo, que es justo el comportamiento que querías evitar. La mayoría de los bugs de "mi stream no transmite" están en el eslabón dos, y volvemos a eso en la sección 05.
02 · Lo más simple que funciona: un stream de texto plano
Arranca con la menor cantidad de maquinaria posible. En el App Router, un route handler puede devolver un Response cuyo body es un ReadableStream. Armas el stream, vas encolando texto codificado a medida que el modelo lo produce, y lo cierras al terminar.
import Anthropic from "@anthropic-ai/sdk"
export const runtime = "nodejs"
const client = new Anthropic()
export async function POST(req: Request) {
const { messages } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const run = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
})
run.on("text", (delta) => controller.enqueue(encoder.encode(delta)))
await run.finalMessage()
controller.close()
},
})
return new Response(stream, {
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
},
})
}
Acá hay tres cosas que hacen el trabajo pesado. El client.messages.stream() del SDK devuelve un helper que emite un evento text por cada delta: no parseas el SSE crudo de Anthropic a mano, el SDK ya lo hizo por ti. controller.enqueue() empuja un chunk codificado al body de la respuesta apenas existe. Y controller.close() no es opcional: si te olvidas, el navegador se queda esperando un final que nunca llega, aunque cada token ya haya llegado. Fíjate que acá el runtime es nodejs; comparamos contra el runtime edge en la sección 06.
Consejo
Ponle cache-control: no-store a toda ruta de streaming. Un CDN o un navegador que cachee una respuesta transmitida puede servirle media respuesta vieja al siguiente usuario, y las capas de caché además tienden a bufferizar, dos problemas resueltos con un solo header.
Este enfoque de texto plano está más que bien para un endpoint de chat de un solo propósito. El body es solo el texto de la respuesta; el cliente concatena los chunks y los pinta. Recurres a más estructura solo cuando necesitas mandar algo más que texto por el cable, que es de lo que va la siguiente sección.
03 · Cuando el texto plano no alcanza: Server-Sent Events de verdad
El texto plano te da un solo canal: la respuesta. Las aplicaciones reales suelen necesitar más: una forma de distinguir "acá va un token" de "acá va un error" de "acá va el conteo final de tokens", e idealmente una forma de que el navegador reconecte tras una caída sin tener que reenviar todo el prompt. Eso es lo que te da Server-Sent Events, y es un formato pequeño y bien especificado.
El formato del cable SSE
SSE es texto plano con apenas un par de reglas. Cada mensaje es un bloque de líneas; una línea en blanco cierra el bloque. Los campos que de verdad vas a usar:
- data: el payload (uno por línea; varias líneas data: en un mismo bloque se unen con saltos de línea).
- event: un nombre opcional, para que el cliente mande cada tipo de mensaje al handler que le toca.
- id: un id opcional que el navegador guarda y reenvía como Last-Event-ID al reconectar.
- retry: un retraso opcional de reconexión, en milisegundos.
Acá tienes la misma ruta, ahora hablando SSE con eventos con nombre para tokens, finalización y errores:
import Anthropic from "@anthropic-ai/sdk"
export const runtime = "nodejs"
const client = new Anthropic()
function sse(event: string, data: unknown) {
return "event: " + event + "\n" + "data: " + JSON.stringify(data) + "\n\n"
}
export async function POST(req: Request) {
const { messages } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (e: string, d: unknown) =>
controller.enqueue(encoder.encode(sse(e, d)))
try {
const run = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
})
run.on("text", (delta) => send("token", delta))
const final = await run.finalMessage()
send("done", { usage: final.usage })
} catch (err) {
send("error", { message: "stream failed" })
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
"connection": "keep-alive",
},
})
}
El content-type ahora es text/event-stream, que es la señal que usan los navegadores y (lo más importante) los proxies para reconocer que esto es un stream. Cada mensaje es un bloque SSE bien cerrado que termina con un doble salto de línea, si te olvidas de la línea en blanco, el cliente nunca dispara el evento, porque el bloque nunca se da por completo.
Atención
El EventSource nativo del navegador solo hace requests GET y no puede mandar un body. Si tu chat necesita mandar por POST un array de mensajes, tienes tres caminos: pasarlo como query param (solo para payloads chicos), abrir el EventSource contra una ruta GET que lee el estado de sesión del lado del servidor, o saltarte EventSource por completo y leer el stream SSE con fetch + un reader (sección 04). No descubras esto después de haber armado toda la UI alrededor de EventSource.
Los heartbeats evitan que la conexión se muera
Las conexiones SSE inactivas se terminan cortando: las cortan los navegadores, los balanceadores de carga, los proxies con timeouts de lectura. Si tu modelo se demora en arrancar a producir tokens (pensando, o en una llamada lenta a una herramienta), la conexión se puede caer antes del primer byte. Una línea de comentario, una línea que arranca con dos puntos, es un keep-alive SSE inofensivo que el cliente ignora. Manda uno cada 15 segundos mientras esperas y la conexión se mantiene viva.
04 · El cliente: lee el stream y pinta los deltas
Un stream que el navegador junta todo de golpe no es un stream. La solución es ir leyendo el body de la respuesta de a poco. Para texto plano o para SSE sobre fetch, usa el reader del body directamente:
async function streamChat(messages: unknown[], onToken: (t: string) => void) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages }),
})
if (!res.body) throw new Error("no response body")
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Separa por el delimitador de bloque SSE; guarda el parcial final en el buffer.
const blocks = buffer.split("\n\n")
buffer = blocks.pop() ?? ""
for (const block of blocks) {
const data = block
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).trim())
.join("\n")
if (data) onToken(JSON.parse(data))
}
}
}
El detalle con el que tropieza todo el mundo: los chunks de la red no caen alineados con los límites de los mensajes. Un solo reader.read() te puede entregar medio bloque SSE, o dos bloques y medio. Por eso vas acumulando en un buffer, separas por el delimitador de doble salto de línea, y te guardas el fragmento que sobre para la próxima lectura. Si te saltas esto, vas a terminar haciendo JSON.parse de medio token y reventando de forma intermitente, ese tipo de bug que solo aparece bajo condiciones reales de red, nunca en localhost.
Consejo
No escribas al DOM en cada token. Re-renderizar en React por cada carácter te va a dejar un núcleo de CPU al máximo en respuestas largas. Acumula los deltas y suéltalos en un intervalo corto (cada ~50ms o cada N tokens) con requestAnimationFrame, el usuario ni nota la diferencia, y tu UI se mantiene fluida.
Si te fuiste por SSE de verdad sobre una ruta GET, el cliente queda más simple: const es = new EventSource("/api/chat"), y después es.addEventListener("token", ...) y es.addEventListener("done", () => es.close()). La reconexión y el enrutamiento de eventos te salen gratis. Lo que pagas a cambio es la restricción de solo GET y sin body de la sección 03.
05 · El detalle que te roba una tarde entera: el buffering
Esta es, por lejos, la razón más común de que el streaming "no funcione" en producción mientras anda perfecto en localhost. Algo entre tu handler y el navegador está juntando la respuesta completa antes de reenviarla, así que el usuario espera todo el tiempo que tarde y después recibe todo de golpe, visualmente idéntico a no tener streaming.
Los sospechosos de siempre, más o menos por orden de probabilidad:
- Un reverse proxy bufferizando la respuesta. nginx bufferiza las respuestas proxeadas por defecto. La solución es desactivarlo para la ruta de streaming: pon proxy_buffering off (o emite el header de respuesta X-Accel-Buffering: no, que nginx respeta). En Traefik, confirma que no haya un middleware de buffering enganchado al router que sirve la ruta.
- Middleware de compresión. Un middleware de gzip/brotli que bufferiza para comprimir todo el body te tira abajo el streaming. Saca las rutas de streaming de la compresión, o apóyate en que text/event-stream no se comprima.
- Tu propio código juntando el stream. Cualquier helper que haga await collect(stream) hacia un string, o un wrapper de analytics que lea el body para registrarlo, te vuelve a convertir el stream en una sola respuesta. Revisa bien el camino que toma la respuesta después de que el handler retorna.
Atención
Prueba el buffering contra el entorno desplegado, no contra localhost. El dev server no tiene ningún reverse proxy adelante, así que en local el streaming siempre se ve bien. El buffering recién aparece cuando Traefik o nginx se meten en el camino. La prueba más barata: curl -N https://tu-app/api/chat (opens in new tab) con un payload, y mira si los bytes gotean o caen todos de golpe. -N desactiva el buffering propio de curl, así que estás probando el servidor y no el cliente.
Como el síntoma (anda en local, roto en prod) apunta hacia el lado contrario de la causa real (un proxy que no escribiste tú), este bug te roba una tarde sin falta. Revísalo de primero cuando un stream que andaba en dev empieza a llegar todo de golpe en producción.
06 · Runtime, cancelación, y no dejar requests colgados
Dos temas de producción que los ejemplos del camino feliz se saltan.
Edge vs Node runtime. El runtime edge arranca rápido y transmite bien, pero no puede usar APIs exclusivas de Node y tiene límites más estrictos. Algunas funciones del SDK, los módulos nativos y las conexiones de larga duración se comportan distinto o directamente no están disponibles. El runtime nodejs es el default seguro para una ruta que mantiene una conexión abierta mientras el modelo genera y, encima, puede llamar herramientas. Opta por edge solo cuando hayas confirmado que todo en el camino es compatible con Web APIs y de verdad quieras los beneficios de arranque en frío y geografía que da el edge. Déjalo explícito con export const runtime para que la decisión se vea, en vez de heredarse.
Cancelación. Cuando un usuario cierra la pestaña o se va a mitad de la respuesta, el navegador aborta el request, pero, a menos que lo escuches, tu handler sigue transmitiendo tokens del modelo al vacío. Te cobran por una salida que nadie va a ver. La solución es el AbortSignal del request: pásalo para que un abort también tumbe la llamada al modelo.
export async function POST(req: Request) {
const { messages } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const run = client.messages.stream(
{ model: "claude-sonnet-4-6", max_tokens: 1024, messages },
{ signal: req.signal },
)
run.on("text", (d) => {
if (!req.signal.aborted) controller.enqueue(encoder.encode(d))
})
try {
await run.finalMessage()
} catch {
// Abortado o error upstream — sigue de largo y cierra.
} finally {
controller.close()
}
},
cancel() {
// El navegador se desconectó — el AbortSignal ya se propagó al SDK.
},
})
return new Response(stream, {
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
})
}
Pasarle req.signal a la llamada del SDK hace que un request abortado se propague hasta Anthropic y corte la generación. El callback cancel() del stream se dispara cuando el consumidor se va, y te da un lugar para limpiar cualquier otra cosa. Sin este cableado, unos pocos usuarios que se van rápido se te vuelven una cuenta de tokens constante e invisible, y en un solo VPS, conexiones upstream que estás manteniendo abiertas para nada.
El streaming es una de esas funciones que son fáciles de demostrar y fáciles de subir a producción sutilmente rotas. Acierta la cadena de tres eslabones (incremental en el servidor, sin buffering en el medio, leído como stream en el cliente) y elige el formato de cable acorde a lo que de verdad necesitas mandar. Después pruébalo donde va a correr de verdad, no donde es cómodo, y deja cableada la cancelación antes de que el primer usuario real le dé al botón de atrás.
Puntos clave
- El streaming es una cadena de tres eslabones: genera los chunks de a poco en el servidor, no dejes que nada los bufferice en el medio, y lee el body como un stream en el cliente. Cualquier eslabón roto se ve idéntico a no tener streaming.
- Devuelve un ReadableStream desde el route handler, encola los deltas codificados a medida que el SDK los emite, y cierra siempre el controller en un bloque finally.
- Elige el formato del cable a conciencia: texto plano para un canal de pura respuesta, SSE de verdad cuando necesites eventos con nombre, ids o reconexión nativa del navegador.
- Los proxies que bufferizan son la falla clásica que solo aparece en prod. Desactiva el buffering del proxy para la ruta y verifícalo con curl -N contra la URL desplegada, nunca en localhost.
- Usa el runtime nodejs por defecto y cablea el AbortSignal del request en la llamada al modelo, para que el usuario que se va corte la generación en vez de inflarte una cuenta de tokens invisible.
Preguntas frecuentes
¿Necesito una librería como el AI SDK de Vercel para transmitir desde un route handler?
No. El App Router permite que un route handler devuelva, de forma nativa, un Response que envuelve un ReadableStream, y el client.messages.stream() del SDK de Anthropic ya emite eventos por cada delta. Una librería de streaming es una comodidad: te empaqueta los hooks de cliente, el parsing y el estado de UI, no un requisito. Recurre a ella cuando quieras todo resuelto de fábrica; el patrón crudo de la sección 02 se basta solo.
¿Uso texto plano o SSE de verdad?
Por defecto, texto plano para un endpoint de un solo propósito donde el body es solo la respuesta. Es lo que menos código pide y el cliente solo tiene que concatenar chunks. Pásate a SSE (text/event-stream con líneas data:) cuando necesites mandar más de un tipo de mensaje, tokens, más errores, más un conteo final de uso, o cuando quieras la reconexión y el replay de Last-Event-ID que ya trae el navegador. Lo que pagas por SSE es la restricción de solo GET y sin body de EventSource, que muchas veces termina empujándote a leer el stream SSE con fetch.
Transmite en localhost pero llega todo de golpe en producción. ¿Por qué?
Casi siempre es un proxy bufferizando. El dev server no tiene nada adelante, así que en local el streaming siempre funciona; en cuanto Traefik o nginx se meten en el camino, un default como el proxy_buffering de nginx junta toda la respuesta antes de reenviarla. Desactiva el buffering para la ruta (proxy_buffering off, o el header X-Accel-Buffering: no), revisa que ningún middleware de compresión esté bufferizando para comprimir, y confirma que tu propio código no esté juntando el stream en un string. Verifícalo con curl -N contra la URL desplegada.
¿Qué pasa si olvido controller.close()?
El navegador se queda colgado. Puede que cada token ya haya llegado y se haya pintado, pero como el stream nunca se cerró, el cliente mantiene la conexión abierta esperando más, la promesa del fetch nunca se resuelve como terminada, y cualquier UI de 'finalizado' nunca se dispara. Da la impresión de que la última respuesta se queda cargando para siempre. Cierra siempre en un bloque finally, para que corra sin importar si el modelo terminó limpio o lanzó un error.
¿Runtime edge o runtime Node para una ruta de chat con streaming?
Por defecto, nodejs para una ruta que mantiene una conexión de larga vida mientras el modelo genera y, quizás, llama herramientas. Es el que menos sorpresas te da y tiene soporte completo del SDK. El runtime edge transmite bien y tiene arranques en frío rápidos, pero solo permite APIs estándar de la Web y tiene límites de ejecución más estrictos, así que confirma que todo lo que hay en tu camino sea compatible con edge antes de elegirlo. Deja la decisión explícita con export const runtime, para que se vea en el archivo en vez de heredarse en silencio.
¿Para qué molestarse en pasar la señal de abort, no se cierra la conexión y ya?
La conexión TCP con el navegador se cierra, sí, pero el trabajo de tu handler no se detiene a menos que se lo digas. Si no le reenvías req.signal a la llamada del SDK, el modelo sigue generando tokens que nunca vas a entregar, y te cobran por esa salida. Pasar la señal propaga el abort hacia arriba, hasta Anthropic, para que la generación se detenga de verdad. En un solo VPS, además, importa por el uso de recursos: dejas de mantener abierta una conexión upstream por una respuesta que nadie está leyendo.
¿Prefieres que lo hagamos por ti?
Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.
Escríbenos por WhatsAppPrimera conversación gratis. Te responde el fundador.
Recursos relacionados

Hacer streaming de llamadas a herramientas de la API de Claude dentro de un bucle
Hacer streaming con herramientas es un bucle agéntico, no una sola llamada: vas emitiendo tokens para que la respuesta se sienta ágil, pausas cuando Claude pide una herramienta, la ejecutas, le devuelves el resultado y sigues con el streaming hasta que el turno se cierra. Esta guía recorre el bucle completo en TypeScript con eventos reales del SDK, las reglas de forma del mensaje que la API exige y los fallos que más duelen en producción.

Despliega Next.js detrás de Traefik con TLS automático
Un recorrido de principio a fin del patrón exacto que uso en ilustrari.com: un contenedor Next.js que no expone nada al exterior, un proxy Traefik dueño del 80/443 y un certificado Let's Encrypt que se emite y se renueva solo. Terminas con un deploy funcionando y una idea clara de por qué cada label está donde está.

Agrega caché de prompts a una llamada de la API de Claude
Marca un prefijo estable como cacheable y bajas latencia y costo en prompts grandes que se repiten. El detalle está en el modelo de match por prefijo, que decide si consigues un hit o terminas pagando precio completo sin enterarte.