Skip to main content

Command Palette

Search for a command to run...

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

Updated
13 min read
Cómo usar AI SDK con Ollama local en una app Next.js

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:8b te 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 hook useChat y 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.parts en lugar de message.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:

  1. Ollama no está corriendo: verificá en http://localhost:11434.

  2. El modelo no está descargado: ollama list para ver qué tenés, ollama pull llama3.2 si falta.

  3. Mismatch de versiones: si estás en AI SDK 5, quitá el await de convertToModelMessages y 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 menos llama3.1:8b o, mejor aún, qwen2.5:7b que 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 streamText con output para 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: