Search IOCombats

Search challenges, guides, questions and articles

The Vercel AI SDK for Frontend Engineers: streamText, useChat and Providers
aivercel ai sdkreactnextjstypescriptstreaming

The Vercel AI SDK for Frontend Engineers: streamText, useChat and Providers

By Ghazi Khan | Nov 9, 2026 - 9 min read

You have probably copied a streamText route and a useChat component from a tutorial, and it worked. Then the real requirements arrive: keep chats after a refresh, switch models without touching ten files, and stop mixing up UIMessage and ModelMessage. This post walks through the Vercel AI SDK end to end, so you know what each piece does and where your own code belongs.

Earlier posts in this series used the SDK as a tool. This one is about the SDK itself. Everything below uses AI SDK 7, which renamed several APIs that most tutorials still show.

How the Vercel AI SDK fits together

The SDK has three layers. If you have built a React app on top of an API, you already know this shape.

LayerPackageMain APIsThink of it as
Providers@ai-sdk/openai, @ai-sdk/anthropic, the AI Gatewayopenai('...'), gateway('...'), customProviderA database driver behind an ORM
CoreaigenerateText, streamTextA server function that returns a promise or stream
UI@ai-sdk/react plus helpers in aiuseChat, DefaultChatTransport, toUIMessageStreamReact Query, scoped to one conversation

Providers hide the differences between model vendors. Core code calls streamText({ model }) and does not care whether model is OpenAI, Anthropic or something routed through the Vercel AI Gateway. In AI SDK 7 the gateway is the default global provider, so a plain string like 'anthropic/claude-sonnet-5.5' works once AI_GATEWAY_API_KEY is set.

Core is the server side. streamText starts a model call and returns a result object with streams (textStream, stream) and promises (text, usage) on it. It runs in Node, a Route Handler or a plain script.

UI is the client side. useChat holds the conversation state, sends requests through a transport and turns the streamed response into React state.

The piece that confuses people most is that there are two message types:

TypeLives inShapeAnalogy
UIMessageuseChat, your databaseid, role, parts (text, tool calls, sources), metadataYour view model
ModelMessageThe request to the modelWhat the provider API expects, no UI-only dataThe DTO you send to an API

convertToModelMessages is the mapper between them. Store and render UIMessage. Convert to ModelMessage right before calling the model.

Here is one request, end to end:

Diagram
100%
flowchart LR A["useChat: sendMessage"] --> B["DefaultChatTransport: POST /api/chat"] B --> C["Route Handler: load history, validateUIMessages"] C --> D["convertToModelMessages"] D --> E["streamText with a provider model"] E --> F["toUIMessageStream"] F --> G["createUIMessageStreamResponse"] G --> H["useChat: messages and parts update"]
visualized byIOCombats

Build it: a Next.js chat with server-side history

Install the packages. AI SDK 7 needs Node.js 22 or later and is ESM-only.

npm i ai @ai-sdk/react

Step 1: one file that knows model ids

Model ids change often. Keep them in one place and give them names that describe the job, not the vendor.

// lib/models.ts
import { customProvider, gateway } from 'ai';

export const models = customProvider({
  languageModels: {
    fast: gateway('anthropic/claude-haiku-5.5'),
    smart: gateway('anthropic/claude-sonnet-5.5'),
  },
  fallbackProvider: gateway,
});

The rest of the app calls models.languageModel('smart'). To move smart to another vendor, change one line. If you prefer calling a vendor directly instead of the gateway, install its package and swap the value, for example openai('gpt-5-mini') from @ai-sdk/openai with OPENAI_API_KEY set.

Step 2: try it from Node first

Before any React, check that the model call works from a script. It is the fastest feedback loop you will get.

// scripts/try-model.ts
import { streamText } from 'ai';
import { models } from '../lib/models';

const result = streamText({
  model: models.languageModel('fast'),
  instructions: 'Answer in two sentences.',
  prompt: 'What does a useEffect cleanup function do?',
});

for await (const chunk of result.textStream) {
  process.stdout.write(chunk);
}

console.log('\n', await result.usage);

Run it with node --env-file=.env.local --import tsx scripts/try-model.ts (install tsx as a dev dependency). Note instructions: in AI SDK 7 it replaces system.

Step 3: a chat store

The server owns history. This file store is enough for local development. In production you swap it for Postgres or Redis, and the function signatures stay the same.

// lib/chat-store.ts
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { UIMessage } from 'ai';

const dir = path.join(process.cwd(), '.chats');

function fileFor(id: string) {
  if (!/^[\w-]+$/.test(id)) throw new Error('Invalid chat id');
  return path.join(dir, `${id}.json`);
}

export async function loadChat(id: string): Promise<UIMessage[]> {
  try {
    return JSON.parse(await readFile(fileFor(id), 'utf8'));
  } catch {
    return []; // a new chat has no file yet
  }
}

export async function saveChat(id: string, messages: UIMessage[]) {
  await mkdir(dir, { recursive: true });
  await writeFile(fileFor(id), JSON.stringify(messages, null, 2));
}

The id check matters: the id comes from the request, and without it someone could send ../ and write outside the folder.

Step 4: the route handler

The client sends only its newest message. The server loads the rest, validates it, calls the model and saves when the stream ends.

// app/api/chat/route.ts
import {
  convertToModelMessages,
  createIdGenerator,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  validateUIMessages,
  type UIMessage,
} from 'ai';
import { models } from '@/lib/models';
import { loadChat, saveChat } from '@/lib/chat-store';

export const maxDuration = 30;

export async function POST(req: Request) {
  const { id, message }: { id: string; message: UIMessage } = await req.json();
  const messages = await validateUIMessages({
    messages: [...(await loadChat(id)), message],
  });

  const result = streamText({
    model: models.languageModel('smart'),
    instructions: 'You are a concise assistant for frontend engineers.',
    messages: await convertToModelMessages(messages),
  });

  result.consumeStream(); // no await: keeps generating if the tab closes

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      originalMessages: messages,
      generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
      onEnd: ({ messages }) => saveChat(id, messages),
    }),
  });
}

Three details are doing real work here. validateUIMessages checks that stored and incoming messages still match the expected shape, which protects you when your schema changes. originalMessages tells the UI stream which history to append to, so onEnd receives the full list including the new assistant reply. consumeStream() without await lets the model finish even if the client disconnects, so the answer still gets saved. The trade-off is that a closed tab no longer cancels the model call. The Node streaming post covers the opposite choice, cancelling with abortSignal.

Diagram
100%
sequenceDiagram participant UI as useChat participant R as Route Handler participant DB as Chat store participant M as Model UI->>R: POST { id, message } R->>DB: loadChat(id) R->>M: streamText(messages) M-->>R: stream parts R-->>UI: UI message stream R->>DB: onEnd: saveChat(id, messages)
visualized byIOCombats

Step 5: the page and the client component

The page is a Server Component that loads saved history and passes it down.

// app/chat/[id]/page.tsx
import { loadChat } from '@/lib/chat-store';
import { Chat } from './chat';

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <Chat id={id} initialMessages={await loadChat(id)} />;
}
// app/chat/[id]/chat.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { useState } from 'react';

const transport = new DefaultChatTransport({
  prepareSendMessagesRequest: ({ id, messages }) => ({
    body: { id, message: messages.at(-1) }, // server owns history
  }),
});
type Props = { id: string; initialMessages: UIMessage[] };

export function Chat({ id, initialMessages }: Props) {
  const { messages, sendMessage, status, stop, error } = useChat({
    id,
    messages: initialMessages,
    transport,
    throttle: 50,
  });
  const [input, setInput] = useState('');
  const busy = status === 'submitted' || status === 'streaming';
  return (
    <main>
      {messages.map((m) => (
        <article key={m.id} aria-label={m.role === 'user' ? 'You' : 'AI'}>
          {m.parts.map((p, i) =>
            p.type === 'text' ? <p key={i}>{p.text}</p> : null,
          )}
        </article>
      ))}
      {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('');
        }}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          aria-label='Message'
        />
        {busy ? (
          <button type='button' onClick={stop}>
            Stop
          </button>
        ) : (
          <button type='submit'>Send</button>
        )}
      </form>
    </main>
  );
}

Open /chat/test-1, send a message, refresh, and the conversation is still there. status moves through submitted, streaming and ready (or error), which is all you need for loading and stop states. For scroll and screen reader behaviour on top of this, see last week's streaming chat UI post.

Common mistakes and their fixes

  1. Passing UIMessage[] straight to the model. UI messages carry ids, parts and metadata the provider does not understand. Fix: await convertToModelMessages(messages) right before streamText.
  2. Following a v5 or v6 tutorial line by line. The old names still work in AI SDK 7 but are deprecated and log warnings. Fix: use the current names.
Older codeAI SDK 7
system: '...'instructions: '...'
result.toUIMessageStreamResponse()createUIMessageStreamResponse({ stream: toUIMessageStream(...) })
result.fullStreamresult.stream
onFinishonEnd
stepCountIs(n)isStepCount(n)
  1. Saving chats from the client. The onFinish callback in useChat never runs if the user closes the tab mid-answer. Fix: save on the server in onEnd, and call consumeStream() so the stream completes.
  2. Trusting history sent by the browser. Anyone can edit a request body and rewrite earlier turns. Fix: send only the newest message, load the rest from your store, and run validateUIMessages.
  3. Model ids scattered across files. A model upgrade becomes a find-and-replace across the codebase. Fix: one customProvider with job-based aliases like fast and smart.

Interview angle

AI SDK questions in frontend interviews rarely ask you to recite an API. They ask you to design a chat: where history lives, how streaming reaches the UI, and how you would switch model vendors. Explaining the provider, Core and UI layers, the UIMessage to ModelMessage conversion and server-side persistence with onEnd covers most follow-ups. Practise with the AI SDK interview questions.

What's next

On Thursday, the case study How AI Writes Inside a Notion-Style Editor uses the same streamText building blocks to stream tool calls into a rich text editor. Next week we look at structured output: getting reliable, typed JSON from a model with Zod.

Conclusion

The Vercel AI SDK is three layers with clear jobs. Providers decide which model runs, Core runs it and streams the result, and UI turns that stream into React state. Keep model ids behind aliases, store UIMessage and convert at the edge, and let the server own and save history. With those three habits, the snippets you copied become an architecture you can explain and extend.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge