Cómo usar AI SDK con Ollama local en una app Next.js
Una guía técnica para construir chatbots con streaming sin depender de APIs externas durante el desarrollo

El problema es conocido: quieres iterar sobre una funcionalidad de IA, pero cada cambio cuesta tokens; y, peor aún, reproducir un bug consume todavía más. Para prototipos, demos internas o cualquier escenario en el que los datos no deban salir de tu máquina, conviene ejecutar un LLM de forma local.
En este post vamos a montar un chatbot completo con AI SDK de Vercel y Ollama, conectados desde una app Next.js 15 con streaming. Lo interesante del setup no es que sirva solo para local: el mismo código corre en producción contra OpenAI, Anthropic o Google cambiando exclusivamente el provider.
Por qué este stack
El AI SDK es un conjunto de herramientas en TypeScript que unifica la interfaz para trabajar con LLMs. Si ya has usado fetch con tipos, la analogía funciona: cada proveedor expone básicamente la misma API REST (completions de chat, streaming por SSE, invocación de herramientas), y el SDK te aporta una capa de tipos y utilidades para no reimplementar la infraestructura de conexión cada vez. No transforma los datos como lo haría un ORM; lo que ofrece es un contrato común para interfaces que ya son similares.
Ollama, por su parte, es una capa sobre llama.cpp que permite descargar y servir modelos de código abierto con un solo comando. Expone una API HTTP en localhost:11434 compatible con el formato OpenAI, lo que lo hace directamente integrable con el AI SDK.
El stack en una imagen
Tres capas, cada una con su responsabilidad. El browser usa useChat para manejar estado de mensajes y streaming, sin saber nada del modelo. El Route Handler es un puente que traduce entre el formato UI y el formato modelo, y delega al provider. Ollama hace el trabajo pesado de generar tokens.
Esta separación importa porque cambiar Ollama por OpenAI más adelante no toca la UI, y cambiar la UI no afecta a Ollama. Es la promesa del SDK y vamos a aprovecharla.
Paso 1: Instalar y correr Ollama
Andá a ollama.com y descargá el instalador para tu sistema operativo. En Mac y Windows queda corriendo como servicio; en Linux probablemente tengas que arrancarlo manualmente con ollama serve.
Bajate un modelo:
ollama pull llama3.2
Esto descarga exactamente 2.0 GB: la variante 3B de Llama 3.2. Es el sweet spot para una laptop moderna: corre fluido sin GPU, soporta 128K de context window, y mantiene calidad suficiente para chat conversacional.
Para verificar:
ollama run llama3.2 "Hola, ¿cómo estás?"
Si Ollama está corriendo bien, http://localhost:11434 te muestra el texto "Ollama is running" en el navegador.
Qué modelo elegir
| Modelo | Tamaño en disco | RAM mínima | Notas |
|---|---|---|---|
llama3.2:1b |
1.3 GB | 4 GB | Para resúmenes simples y routing |
llama3.2 (3B) |
2.0 GB | 8 GB | Sweet spot para chat |
llama3.1:8b |
4.7 GB | 16 GB | Mejor calidad, requiere más RAM |
qwen2.5:7b |
4.7 GB | 16 GB | Mejor para razonamiento y código |
mistral-small3.1 |
14 GB | 24 GB | Cerca de GPT-4-mini en calidad |
Para todo lo que sigue voy a usar
llama3.2(el de 3B). Si tu hardware lo permite,llama3.1:8bte va a dar respuestas notablemente mejores, especialmente para tool calling.
Paso 2: Crear el proyecto Next.js
npx create-next-app@latest mi-chat-ollama
cd mi-chat-ollama
TypeScript, App Router y Tailwind (es lo que asume el código de abajo).
npm install ai @ai-sdk/react ai-sdk-ollama
Qué hace cada una:
ai: el core.streamText,generateText,convertToModelMessages,tool.@ai-sdk/react: la capa React. El hookuseChaty utilidades para conectar UI con stream.ai-sdk-ollama: el provider de Ollama. Hay tres providers de Ollama en el ecosistema (ollama-ai-provider,ollama-ai-provider-v2,ai-sdk-ollama). Uso este último porque está construido sobre el cliente oficial de Ollama y se mantiene más activamente.
Paso 3: Entender el flujo de un request
Antes del código, vale la pena ver qué pasa cuando el usuario manda un mensaje. Esto ayuda a debuggear si algo se rompe.
Tres cosas a notar:
El streaming es token por token. Cada palabra que aparece en pantalla corresponde a un evento SSE que viene del servidor. La UI lo reensambla en tiempo real.
Hay dos formatos de mensaje. UIMessage viaja entre UI y servidor: tiene parts (que pueden ser texto, imágenes, tool calls), id, metadata. ModelMessage es lo que el modelo entiende: más crudo, role + content. convertToModelMessages traduce uno al otro.
El servidor solo retransmite. Next.js no genera tokens, los recibe de Ollama y los reformatea al protocolo del AI SDK. Si la UI no actualiza pero ves tokens en los logs del servidor, casi siempre es porque el formato de salida está mal.
Paso 4: El endpoint de chat
Creá app/api/chat/route.ts:
import { streamText, convertToModelMessages, UIMessage } from 'ai';
import { createOllama } from 'ai-sdk-ollama';
export const maxDuration = 30;
const ollama = createOllama({
baseURL: 'http://localhost:11434/api',
});
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: ollama('llama3.2'),
system: 'Sos un asistente amigable que responde en español de forma concisa.',
messages: await convertToModelMessages(messages), // 👈await en AI SDK 6
});
return result.toUIMessageStreamResponse();
}
export const maxDuration = 30 define el timeout máximo del Route Handler. Por defecto, Next.js corta funciones serverless después de 10 segundos. Para streaming de un modelo lento, querés más margen. En local con Ollama esto no afecta, pero te ahorra dolores de cabeza al deployar.
createOllama({ baseURL }) crea una instancia del provider apuntando al servidor de Ollama. El path /api al final es necesario porque Ollama expone sus endpoints bajo ese prefijo.
UIMessage[] como tipo del body. El hook useChat envía los mensajes en este formato, con parts y metadata. Tiparlo explícitamente acá te da autocomplete y validación.
await convertToModelMessages(messages) es la traducción de formato UI a formato modelo. Acá está el cambio entre AI SDK 5 y 6: en v5 no se awaiteaba, en v6 sí (es async para soportar herramientas con transformaciones async).
streamText({ model, system, messages }) llama al modelo y devuelve un stream. El system prompt define la personalidad: sin él, llama3.2 a veces responde en inglés aunque le hables en español.
result.toUIMessageStreamResponse() convierte el stream interno al formato Server-Sent Events que useChat entiende del otro lado. Sin esto, el cliente recibe datos pero no sabe parsearlos.
Paso 5: La interfaz de chat
Reemplazá app/page.tsx:
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export default function Chat() {
const [input, setInput] = useState('');
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const isLoading = status === 'streaming' || status === 'submitted';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput('');
};
return (
<div className="flex flex-col w-full max-w-2xl mx-auto py-12 px-4 h-screen">
<h1 className="text-2xl font-bold mb-4">Chat con Ollama Local</h1>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((message) => (
<div
key={message.id}
className={`p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-100 ml-auto max-w-[80%]'
: 'bg-gray-100 mr-auto max-w-[80%]'
}`}
>
<div className="font-semibold text-sm mb-1">
{message.role === 'user' ? 'Vos' : 'Asistente'}
</div>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return <div key={i} className="whitespace-pre-wrap">{part.text}</div>;
}
return null;
})}
</div>
))}
{isLoading && (
<div className="text-gray-500 text-sm">Pensando...</div>
)}
{error && (
<div className="text-red-500 text-sm">
Error: {error.message}
</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Escribí tu mensaje..."
disabled={isLoading}
className="flex-1 p-2 border rounded-lg"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
>
Enviar
</button>
</form>
</div>
);
}
useChat con DefaultChatTransport
En AI SDK 5 la API del hook cambió bastante con respecto a v4. Antes manejaba el input internamente (te daba input, handleInputChange, handleSubmit integrados). Ahora la idea es: manejá tu input y solo pedile al hook que envíe mensajes con sendMessage. Esto te da flexibilidad para integrar con react-hook-form, server actions, lo que sea.
El DefaultChatTransport define cómo se comunica el hook con el backend. Por defecto manda POST al endpoint con los mensajes en el body. Si necesitás algo distinto (WebSockets, auth headers custom), creás tu propio transport.
Por qué
message.partsen lugar demessage.content
Este es el cambio más interesante de v5. Antes los mensajes eran:
{ role: 'assistant', content: 'Hola, soy tu asistente' }
Ahora son:
{
role: 'assistant',
parts: [
{ type: 'text', text: 'Hola, soy tu asistente' },
{ type: 'tool-call', toolName: 'searchWeb', args: {...} },
{ type: 'tool-result', result: {...} },
{ type: 'reasoning', text: 'El usuario pidió X así que...' }
]
}
La razón es que los LLMs modernos no solo generan texto: invocan tools, citan fuentes, generan imágenes, exponen su razonamiento. Tener un array de parts permite representar todo eso de forma estructurada, en lugar de mezclar todo dentro de un string.
Para chat simple solo manejás part.type === 'text', pero la arquitectura te deja crecer sin reescribir.
Los cuatro estados de status
useChat te da un status con valores:
'ready'(idle)'submitted'(mensaje enviado, esperando respuesta)'streaming'(recibiendo tokens)'error'(algo falló)
Permite UIs más ricas: por ejemplo, "Pensando..." durante submitted y "Escribiendo..." durante streaming.
Paso 6: Probar la app
Con Ollama corriendo:
npm run dev
Andá a http://localhost:3000. Mandale un mensaje y vas a ver la respuesta aparecer token por token.
Si nada pasa o ves errores, los tres problemas más comunes:
Ollama no está corriendo: verificá en
http://localhost:11434.El modelo no está descargado:
ollama listpara ver qué tenés,ollama pull llama3.2si falta.Mismatch de versiones: si estás en AI SDK 5, quitá el
awaitdeconvertToModelMessagesy usáai-sdk-ollama@^2.
Tool calling: convertir el chat en agente
Una capacidad clave del AI SDK es darle herramientas al modelo. Esto convierte el chat en un agente: en lugar de solo generar texto, puede ejecutar funciones del mundo real.
Advertencia importante sobre modelos chicos: tool calling con
llama3.2(3B) funciona pero es vago. El modelo a veces falla al estructurar los argumentos, otras veces invoca tools cuando no debería, y en chats largos pierde el hilo. Para tool calling confiable, usá al menosllama3.1:8bo, mejor aún,qwen2.5:7bque está optimizado para esto. Si vas a producción, considerá un modelo cloud.
Cómo funciona
Lo clave: el modelo no ejecuta nada, solo decide qué tool llamar y con qué argumentos. La ejecución sucede en tu servidor (en la función execute de la tool). El resultado vuelve al modelo, que lo usa para generar la respuesta final.
Esto significa que las tools son tu puente con el mundo exterior: pueden leer una DB, llamar APIs, hacer cálculos, lo que quieras.
Implementación
import { streamText, convertToModelMessages, tool, UIMessage } from 'ai';
import { createOllama } from 'ai-sdk-ollama';
import { z } from 'zod';
const ollama = createOllama({
baseURL: 'http://localhost:11434/api',
});
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: ollama('llama3.1:8b'), // 👈 modelo más grande para tool calling
system: 'Sos un asistente útil. Si te preguntan el clima, usá la herramienta correspondiente.',
messages: await convertToModelMessages(messages),
tools: {
obtenerClima: tool({
description: 'Obtener el clima actual de una ciudad específica',
parameters: z.object({
ciudad: z.string().describe('El nombre de la ciudad'),
}),
execute: async ({ ciudad }) => {
// Acá llamarías a una API real de clima
return {
ciudad,
temperatura: Math.floor(Math.random() * 30) + 5,
condicion: 'Soleado',
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}
El description es crítico. El modelo decide cuándo usar la tool basándose en esta descripción. Tiene que ser específica. "Obtener el clima" funciona peor que "Obtener el clima actual de una ciudad específica".
Zod define el schema. Los parameters no solo dan tipos en TypeScript, también se convierten a JSON Schema que el modelo usa para saber qué argumentos pasar.
execute corre en tu servidor. Tiene acceso a todo: tu DB, secrets, APIs internas. El modelo nunca ve este código.
Cambiando de provider sin tocar el frontend
Acá es donde el SDK nos sigue sorprendiendo, imaginá que querés probar con Anthropic antes de deployar, o que en producción usás un modelo cloud pero localmente preferís Ollama.
El cambio se hace solo en el endpoint, todo lo demás queda igual:
// Ollama local
import { createOllama } from 'ai-sdk-ollama';
const ollama = createOllama({ baseURL: 'http://localhost:11434/api' });
const model = ollama('llama3.2');
// OpenAI
import { openai } from '@ai-sdk/openai';
const model = openai('gpt-4o-mini');
// Anthropic
import { anthropic } from '@ai-sdk/anthropic';
const model = anthropic('claude-sonnet-4-5');
El resto del código (frontend, hooks, tipos, tools) queda intacto. Setup típico para switchear por env var:
const model = process.env.USE_LOCAL === 'true'
? ollama('llama3.2')
: openai('gpt-4o-mini');
Consideraciones para producción
Esta config con Ollama local es ideal para desarrollo, demos internas y casos donde la privacidad de datos importa. Para deployar a producción, hay que pensar tres cosas:
El problema del localhost. Vercel y cualquier serverless no pueden acceder a localhost:11434. Las opciones son: exponer Ollama vía túnel (ngrok, Cloudflare Tunnel) para demos, hostear Ollama en un servidor con GPU para uso real, o pasarse a un provider cloud para producción manteniendo Ollama solo para dev, esta última suele ser la opción más práctica.
Ollama Cloud es una opción intermedia: te da acceso a modelos grandes (gpt-oss:120b-cloud, qwen3-coder:480b-cloud) usando la misma API, sin tener que self-hostear.
Context window. Los modelos locales típicamente tienen ventanas más chicas que los comerciales. Si tus conversaciones se hacen largas, vas a tener que implementar alguna tecnica para controlar esto como, summarization de mensajes viejos, o RAG. El AI SDK no maneja esto automáticamente: es lógica que vos implementás antes de pasar los mensajes a streamText.
Próximos pasos
Persistencia: guardar el historial en una DB para que las conversaciones sobrevivan al refresh.
Evals: medir cuán bien tu modelo cumple con tus casos de uso, especialmente cuando cambies entre proveedores.
RAG: indexar tus propios documentos y darle contexto al modelo para responder sobre ellos.
Structured outputs: usar
streamTextconoutputpara que el modelo te devuelva JSON tipado con Zod.
De estos cuatro, el que cambia más cómo desarrollás con IA es el de evals, porque te saca del modo "funciona en mi compu" y te da feedback real sobre regresiones cuando tocás prompts o cambiás modelos.
Recursos útiles:
Ollama Library — todos los modelos disponibles





