Building AI-Powered Apps with the Vercel AI SDK

23. May, 2026 14 min read Develop

Making the model a parameter

Every provider ships its own SDK, and every one of them has a different opinion about what a stream looks like, how a tool call is shaped, and what comes back when the model refuses to answer. Write against OpenAI directly and you have written against OpenAI forever. The Vercel AI SDK is the layer that turns the provider into a parameter instead of an architecture decision you have to live with.

The catch is that the SDK moves. Version 5 rewrote the React hooks from the ground up, version 6 deprecated the function most people learned first, and one of its three layers has carried an “experimental” label for its entire life. A post that reprints the quickstart would be stale by autumn. So what follows is a map of the pieces, with an opinion attached wherever I have one about which of them I would actually build on.

If you have read my post on the rise of AI agents, this is the plumbing underneath that story. Nothing here is conceptually hard. Most of the difficulty is in knowing which API survived the last major version.

Three layers, one of which I ignore

The SDK is really three packages wearing a trench coat.

AI SDK Core is the part that matters. generateText, streamText, embed, embedMany and friends behave identically no matter which model you point them at. It runs anywhere TypeScript runs: a route handler, a cron job, a CLI, a worker.

AI SDK UI is the framework layer. useChat for conversations, useObject for progressively filling structured data. React, Svelte and Vue are supported; useObject in particular is still flagged experimental and is not available everywhere.

AI SDK RSC streams React Server Components straight from the server as the model produces them. It is the flashiest of the three and the one I would leave alone. More on why later.

You install what you need, nothing more:

npm install ai zod

That is genuinely all it takes now. The provider packages (@ai-sdk/openai, @ai-sdk/anthropic and the rest) are thin adapters, and since the AI Gateway became the default provider you can skip them entirely for the common models and pass a plain string instead.

Calling a model

The smallest useful call gives you a model and a prompt and hands back text:

import { generateText } from 'ai';

const { text, usage, finishReason } = await generateText({
  model: 'openai/gpt-5',
  prompt: 'Explain quantum entanglement in two sentences.',
});

console.log(text);
console.log(`Used ${usage.totalTokens} tokens`);

Moving to Anthropic is a string edit:

const { text } = await generateText({
  model: 'anthropic/claude-opus-4-7',
  prompt: 'Explain quantum entanglement in two sentences.',
});

provider/model strings resolve through Vercel’s AI Gateway, which reads AI_GATEWAY_API_KEY from the environment. If you would rather talk to a provider directly, import its package and pass openai('gpt-5') instead. Both routes land on the same call signature, and the shape that comes back (text, usage, finishReason, warnings) does not change either. System prompts, message history, temperature and stop sequences all work the way you would expect.

Streaming

For anything a human is waiting on, streamText replaces generateText:

import { streamText } from 'ai';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: 'openai/gpt-5',
    system: 'You are a helpful assistant.',
    messages,
  });

  return result.toUIMessageStreamResponse();
}

toUIMessageStreamResponse() returns a Response whose body speaks the SDK’s own stream protocol, which is what the client hook knows how to parse. If you want the raw material instead, result.textStream gives you nothing but text deltas, and result.fullStream gives you every event that passes through: text, tool calls, tool results, finish reasons.

Structured output, and the rug pull

Getting typed JSON out of a model is the single most useful thing the SDK does, and it is also where version 6 caused the most grumbling. generateObject and streamObject were the headline feature for two years. They are now deprecated in favour of an output option on the ordinary text functions:

import { generateText, Output } from 'ai';
import { z } from 'zod';

const { output } = await generateText({
  model: 'openai/gpt-5',
  output: Output.object({
    schema: z.object({
      name: z.string(),
      ingredients: z.array(
        z.object({ name: z.string(), amount: z.string() })
      ),
      steps: z.array(z.string()),
      prepMinutes: z.number(),
    }),
  }),
  prompt: 'Generate a recipe for sourdough focaccia.',
});

for (const item of output.ingredients) {
  console.log(`${item.amount} ${item.name}`);
}

There is a defensible reason for the change. Output.object() composes with tool calling, whereas generateObject was a dead end that could not do both at once. Output.array(), Output.choice() and Output.json() round out the set, and the streaming equivalent renamed partialObjectStream to partialOutputStream. Fine. It is still a lot of churn for a function that was doing its job, and if you follow a tutorial written before December 2025 you will be writing deprecated code without any warning.

Underneath, the SDK still uses whatever native structured-output mode the provider offers and validates the result against your schema. A response that does not fit throws, so wrap it and retry rather than assuming the model behaved.

Tools

A tool is a function you let the model call. The SDK normalises the shape across providers so you write it once:

import { streamText, stepCountIs, tool } from 'ai';
import { z } from 'zod';

const result = streamText({
  model: 'openai/gpt-5',
  messages,
  tools: {
    getWeather: tool({
      description: 'Get the current weather for a city',
      inputSchema: z.object({
        city: z.string().describe('The city name, for example "Zurich"'),
        units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
      }),
      execute: async ({ city, units }) => fetchWeather(city, units),
    }),
    refundOrder: tool({
      description: 'Refund an order in full',
      inputSchema: z.object({ orderId: z.string() }),
      needsApproval: true,
      execute: async ({ orderId }) => refund(orderId),
    }),
  },
  stopWhen: stepCountIs(5),
});

The model reads the descriptions and the schemas, picks a tool, and the SDK runs your execute and feeds the return value back as a tool result. stopWhen caps how many rounds of that the model gets. Version 6 defaults it to twenty steps, which is a much better default than the old one but is still twenty chances to call a paid API in a loop, so set it deliberately.

needsApproval is the piece I would not skip. Mark any tool that spends money, deletes something or sends a message, and the SDK pauses instead of executing, handing you an approval request to surface in the UI. Before version 6 you did this by leaving execute off and reimplementing the round trip yourself, which worked but was fiddly enough that plenty of people quietly didn’t bother.

Write the .describe() calls on your schema fields, by the way. The model reads them, and vague field names are the most common reason a tool gets called with nonsense arguments.

useChat, after the rewrite

useChat is the workhorse of the UI layer, and version 5 changed it enough that almost every example on the internet is now wrong. The hook no longer owns your input state, handleSubmit and handleInputChange are gone, and the endpoint is configured through a transport rather than an api string.

'use client';

import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

export function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status, stop } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
  });

  return (
    <div className="flex flex-col gap-4">
      {messages.map((message) => (
        <div key={message.id} className={message.role === 'user' ? 'text-right' : ''}>
          {message.parts.map((part, index) => {
            if (part.type === 'text') {
              return <span key={index}>{part.text}</span>;
            }
            if (part.type === 'tool-getWeather') {
              return <WeatherCall key={index} part={part} />;
            }
            return null;
          })}
        </div>
      ))}

      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!input.trim()) return;
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          value={input}
          onChange={(event) => setInput(event.target.value)}
          placeholder="Ask something"
          disabled={status !== 'ready'}
        />
        {status === 'streaming' && (
          <button type="button" onClick={stop}>
            Stop
          </button>
        )}
      </form>
    </div>
  );
}

I was annoyed by the input-state change when I first hit it, and I have come round. Owning the useState yourself means you can prefill from a query string, keep a draft in local storage or drive the box from a keyboard shortcut without fighting the hook. The old convenience was hiding about four lines of code.

The parts array is the other thing worth internalising. A single assistant message can carry text, tool calls, tool results, reasoning and files, and each tool gets its own part type named tool-<toolName>. So a weather tool produces tool-getWeather parts, each with a state that walks from streaming input through to output-available or output-error. Render by part type and by state, and the awkward intermediate moments (a tool that has been called but has not returned) stop being a special case.

status moves through submitted, streaming, ready and error. Use it for the disabled state and for a stop button, because long responses that cannot be interrupted feel broken even when they are working.

To restore a saved conversation, pass id and an initial messages array from the server. The old initialMessages prop is gone.

Generative UI, and why I skip it

The RSC layer lets the model choose a React component, and the server streams that component to the browser. streamUI picks a tool, the tool’s generate function yields a skeleton, fetches data, then yields the real thing. Written down it sounds wonderful, and the demos are genuinely impressive.

It is also, still, experimental, and Vercel’s own documentation says as much and points you at AI SDK UI for production. The known problems are not cosmetic: components remounting and flickering mid-stream, Suspense boundaries falling over, and data transfer that grows quadratically as the stream goes on. The API surface has also shifted between releases, and error recovery is nowhere near as mature as the client-hook path.

My take is that generative UI is a real idea implemented too early. You can get most of the benefit without RSC by having the model call a tool, rendering that tool’s part on the client with a component you chose, and keeping the server doing what servers are good at. That is one indirection more than streamUI and roughly infinitely fewer surprises at 2am.

Embeddings

The SDK is not only for chat. embedMany is the least ceremonious way I know to get vectors:

import { embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';

const { embeddings } = await embedMany({
  model: openai.embedding('text-embedding-3-large'),
  values: docs.map((doc) => doc.content),
});

await db.documents.insertMany(
  docs.map((doc, index) => ({ ...doc, embedding: embeddings[index] }))
);

Use embed for a single query vector and run a similarity search against your store. Both Drizzle and Prisma handle pgvector columns now, so a normal Postgres database is usually enough and you can put off adding a dedicated vector service until you actually have the volume to justify one.

The gateway

Because every call takes the same shape, choosing a model can be ordinary application logic:

function pickModel(task: 'fast' | 'smart' | 'cheap') {
  switch (task) {
    case 'fast':
      return 'google/gemini-2.5-flash';
    case 'smart':
      return 'anthropic/claude-opus-4-7';
    case 'cheap':
      return 'openai/gpt-5-mini';
  }
}

const { text } = await generateText({ model: pickModel('smart'), prompt });

Those strings all go through the AI Gateway, which gives you one API key instead of four, per-request analytics, one invoice, and failover to a model you nominate when the upstream provider falls over. That last point is the one that sold me. Provider outages are frequent enough to plan for, and turning one into a logged warning rather than a paged engineer is worth a small amount of added latency.

If you would rather not route through Vercel, nothing forces you to. Keep the provider packages and the rest of your code is unchanged.

Agent loops

When a task needs several rounds of tool use, ToolLoopAgent packages the loop:

import { ToolLoopAgent, stepCountIs } from 'ai';

const supportAgent = new ToolLoopAgent({
  model: 'anthropic/claude-opus-4-7',
  instructions: 'You are a support engineer. Investigate with the tools before answering.',
  tools: { searchTickets, readLogs, queryDatabase },
  stopWhen: stepCountIs(10),
});

const { text, steps } = await supportAgent.generate({
  prompt: 'Investigate why customer 4271 cannot log in.',
});

If you used this in version 5 it was called Experimental_Agent and took system rather than instructions. Same idea, renamed.

The genuinely valuable return value is steps. Each entry holds the reasoning, the tool calls and their results for one round of the loop. Log it. When an agent does something baffling in production, that array is the difference between a debugging session and a shrug.

Middleware and telemetry

Every model call can be wrapped, which is where caching, redaction and logging belong:

import { wrapLanguageModel } from 'ai';

const model = wrapLanguageModel({
  model: 'openai/gpt-5',
  middleware: {
    wrapGenerate: async ({ doGenerate, params }) => {
      const key = hashParams(params);
      const cached = await redis.get(key);
      if (cached) return JSON.parse(cached);

      const result = await doGenerate();
      await redis.set(key, JSON.stringify(result), 'EX', 3600);
      return result;
    },
  },
});

Middleware can also implement transformParams to rewrite a call before it leaves, and wrapStream for the streaming path.

For telemetry, pass experimental_telemetry: { isEnabled: true } and the SDK emits OpenTelemetry spans covering the request, the tool calls and the token usage. Point those at whatever you already run. I send mine to PostHog, which has an LLM analytics view that turns the spans into per-feature cost, and it took about ten minutes.

Is the layer worth it?

Concern Provider SDK directly Vercel AI SDK
Changing model Rewrite the call site Edit a string
Streaming Provider-specific SSE One protocol, one parser
Tool calling A different shape per provider One tool() helper
Structured output Hand-rolled schema handling Output.object() with Zod
Approvals Build the round trip needsApproval: true
React Write the hook yourself useChat
Multi-step loops Write the loop yourself ToolLoopAgent + stopWhen

The cost is an abstraction between you and the model, and a maintainer who has shown they will deprecate things. The benefit is that almost none of your application code cares which model is behind it.

For most projects that trade is easy. Where I would think twice: research code that needs a provider feature the week it ships, and a one-file script that calls one model once and exits. The raw openai package is perfectly pleasant for that, and adding a framework to it is a waste of everyone’s afternoon.

A handful of habits

Things I would tell myself before the first project rather than after it.

  1. Set stopWhen explicitly on every tool-enabled call, even though the default is now sane.
  2. Put needsApproval: true on anything destructive before you write the happy path, not after the first accident.
  3. Show tool calls in the interface instead of hiding them. People trust an agent they can watch.
  4. Persist messages server-side from onFinish in the route handler. Do not trust the client to hand back a complete transcript.
  5. Turn telemetry on from day one. Working out where the money went is much harder retroactively.
  6. Cache anything whose prompt does not depend on user input. A summarisation call behind a middleware cache costs a fraction of what it did unwrapped.

What I’d watch next

The two things I want are boring ones. Better long-term stability, because three major versions in eighteen months is a lot of migration work for a dependency this central. And an honest decision about the RSC layer, which has been experimental long enough that it should either graduate or be retired rather than sitting in the docs tempting people.

Neither of those is a reason not to use it. Core alone earns the install, and useChat saves an afternoon per project. Just pin your version, read the migration guide before you upgrade, and treat anything labelled experimental as exactly that

‘Till next time!