---
title: "AI SDK 5 with Vue: the Chat class, streamText and typed messages · vuejs.school"
description: "AI SDK 5 splits UI messages from model messages, replaces Vue's useChat with a Chat class and types every tool part. Build a Nuxt 4 chat page with it."
canonical_url: "https://vuejs.school/community/blog/ai-sdk-5-with-vue"
meta:
  "og:description": "AI SDK 5 splits UI messages from model messages, replaces Vue's useChat with a Chat class and types every tool part. Build a Nuxt 4 chat page with it."
  "og:image": "https://vuejs.school/_og/d/a_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcw%2Cdescription_~QUkgU0RLIDUgc3BsaXRzIFVJIG1lc3NhZ2VzIGZyb20gbW9kZWwgbWVzc2FnZXMsIHJlcGxhY2VzIFZ1ZSdzIHVzZUNoYXQgd2l0aCBhIENoYXQgY2xhc3MgYW5kIHR5cGVzIGV2ZXJ5IHRvb2wgcGFydC4gQnVpbGQgYSBOdXh0IDQgY2hhdCBwYWdlIHdpdGggaXQu%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA2IG1pbiByZWFk%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9haS1zZGstNS13aXRoLXZ1ZSI%2Cs_2GHVNOn2zWmnh8RU.png"
  "og:image:alt": "AI SDK 5 with Vue: the Chat class, streamText and typed messages · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "AI SDK 5 with Vue: the Chat class, streamText and typed messages · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcw%2Cdescription_~QUkgU0RLIDUgc3BsaXRzIFVJIG1lc3NhZ2VzIGZyb20gbW9kZWwgbWVzc2FnZXMsIHJlcGxhY2VzIFZ1ZSdzIHVzZUNoYXQgd2l0aCBhIENoYXQgY2xhc3MgYW5kIHR5cGVzIGV2ZXJ5IHRvb2wgcGFydC4gQnVpbGQgYSBOdXh0IDQgY2hhdCBwYWdlIHdpdGggaXQu%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA2IG1pbiByZWFk%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9haS1zZGstNS13aXRoLXZ1ZSI%2Cs_2GHVNOn2zWmnh8RU.png"
  "twitter:image:alt": "AI SDK 5 with Vue: the Chat class, streamText and typed messages · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What shipped

The announcement lists these as the headline changes:

- Two message types. `UIMessage` is what your app stores and renders; `ModelMessage` is what goes to the model. You convert one to the other explicitly.
- Messages are lists of `parts`: text, reasoning, files, typed tool parts named `tool-<name>`, and typed `data-<name>` parts you stream yourself. The old `content` string is gone.
- A transport layer. `useChat` no longer takes an `api` option; it takes a `transport`, with `DefaultChatTransport` for the usual HTTP route and room for your own.
- Vue and Svelte have complete feature parity with React, and Angular support is new.
- Agent loop control: `stopWhen` replaces `maxSteps`, and `prepareStep` lets you change the model, tools or messages between steps.
- Tool definitions rename `parameters` to `inputSchema`, tool calls carry `input` instead of `args` and `output` instead of `result`, and tool input streaming is always on.
- Server-Sent Events replace the custom stream protocol.
- Zod 3 and Zod 4 schemas are both accepted; the announcement recommends Zod 4 for new projects.

The [migration guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0) lists the versions that belong together: `ai` 5.0.0, `@ai-sdk/provider-utils` 3.0.0, and 2.0.0 for the other `@ai-sdk/*` packages, UI and provider packages included. For Vue that means `@ai-sdk/vue@2`.

## UI messages, model messages and parts

Version 4 already had a `Message` type for the screen and a `CoreMessage` type for the model, but `streamText` accepted either and converted UI messages quietly. Version 5 renames the two and makes the line between them explicit:

- `UIMessage` has an `id`, a `role`, optional typed `metadata` and an ordered `parts` array. It is the source of truth for what the user saw, and the announcement recommends persisting exactly that.
- `ModelMessage` is the leaner shape a provider expects.

The server converts with one call, `convertToModelMessages(messages)`, right before `streamText`. Skip it and `streamText` fails with an `AI_InvalidPromptError`: "Invalid prompt: The messages must be a ModelMessage. If you have passed a UIMessage, you can use convertToModelMessages to convert them." The route sends its answer back with `toUIMessageStreamResponse()`, which replaces `toDataStreamResponse()`.

Parts are what make rendering honest. A single assistant turn can contain a text part, then a tool call, then more text, and the `parts` array keeps that order. Each tool part has a `type` of `tool-weather` for a tool named `weather` and a `state` that moves through `input-streaming`, `input-available`, `output-available` and, on failure, `output-error`. A template branches on `type` and `state` and never guesses.

## In Vue, useChat becomes Chat

The [`@ai-sdk/vue` 2.0 changelog](https://github.com/vercel/ai/blob/ai%405.0.0/packages/vue/CHANGELOG.md) is blunt: "replace useChat with new Chat". The package exports a `Chat` class and `useCompletion`; there is no `useChat` composable any more. You construct `new Chat({ transport })` and read `chat.messages`, `chat.status` and `chat.error` in the template. To act on the chat, call `chat.sendMessage()`, `chat.stop()` or `chat.regenerate()`; to replace the history, assign a new array to `chat.messages`. The class keeps its state in Vue refs internally, so the template updates without any wrapping on your side.

Two habits from version 4 go. The input field is no longer managed for you: you own a `ref` and clear it after sending, so `handleInputChange` and `handleSubmit` go too. And `isLoading` is replaced by `status`, which is one of `'submitted'`, `'streaming'`, `'ready'` or `'error'`. That lets a Send button be disabled while a request is in flight and a Stop button enabled only while tokens arrive.

## Build the server route and a shared tool

The example uses the Nuxt 4 layout: `app/pages/` for the page, `server/api/` for the route and `shared/` for what both need. Install the three packages and a schema library:

```
npm install ai@5 @ai-sdk/vue@2 @ai-sdk/openai@2 zod@4
```

Put the key in runtime config, so it is read from `NUXT_OPENAI_API_KEY` on the server and never bundled for the browser:

*nuxt.config.ts*

```
export default defineNuxtConfig({
  runtimeConfig: {
    openaiApiKey: '',
  },
})
```

The tool and the message type live in `shared/`, which Nuxt 4 compiles for both the app and the server. That is what lets the page and the route agree on one type:

*shared/utils/chatTools.ts*

```
import { tool, type InferUITools, type UIDataTypes, type UIMessage } from 'ai'
import { z } from 'zod'

export const chatTools = {
  weather: tool({
    description: 'Get the current temperature for a city',
    inputSchema: z.object({ city: z.string() }),
    execute: async ({ city }) => ({ city, celsius: 18 + Math.round(Math.random() * 10) }),
  }),
}

export type ChatMessage = UIMessage<never, UIDataTypes, InferUITools<typeof chatTools>>
```

`InferUITools` reads the `inputSchema` and the return type of `execute` for each tool and produces the type of every `tool-*` part. `UIMessage` takes three type arguments: metadata, data parts and tools; `never` says this chat has no metadata. The route then does three things: read the UI messages, convert them, stream back:

*server/api/chat.post.ts*

```
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, stepCountIs, streamText } from 'ai'
import { chatTools, type ChatMessage } from '#shared/utils/chatTools'

export default defineLazyEventHandler(() => {
  const apiKey = useRuntimeConfig().openaiApiKey
  if (!apiKey) throw new Error('Set NUXT_OPENAI_API_KEY')
  const openai = createOpenAI({ apiKey })

  return defineEventHandler(async (event) => {
    const { messages } = await readBody<{ messages: ChatMessage[] }>(event)
    const result = streamText({
      model: openai('gpt-4o'),
      system: 'Answer briefly. Use the weather tool when asked about weather.',
      messages: convertToModelMessages(messages),
      tools: chatTools,
      stopWhen: stepCountIs(3),
    })
    return result.toUIMessageStreamResponse()
  })
})
```

`defineLazyEventHandler` runs the setup once, on the first request, so the provider is created a single time. The page's transport (below) posts the chat's `id`, its `messages` and a `trigger`; the route reads only `messages`.

`stopWhen: stepCountIs(3)` is the new multi-step control: without it the default is one step, so the model would call the tool and stop before it could use the answer. `toUIMessageStreamResponse()` streams Server-Sent Events that the client's transport understands.

## Render message parts in the page

The page creates the chat and renders each message part by part:

*app/pages/chat.vue*

```
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'
import type { ChatMessage } from '#shared/utils/chatTools'

const chat = new Chat<ChatMessage>({
  transport: new DefaultChatTransport({ api: '/api/chat' }),
})
const input = ref('')
const busy = computed(() => chat.status === 'submitted' || chat.status === 'streaming')

function send() {
  const text = input.value.trim()
  if (!text || busy.value) return
  chat.sendMessage({ text })
  input.value = ''
}
</script>

<template>
  <section>
    <ul>
      <li v-for="message in chat.messages" :key="message.id">
        <strong>{{ message.role === 'user' ? 'You' : 'Assistant' }}</strong>
        <template v-for="(part, index) in message.parts" :key="index">
          <p v-if="part.type === 'text'">{{ part.text }}</p>
          <p v-else-if="part.type === 'tool-weather' && part.state === 'output-available'">
            {{ part.output.city }}: {{ part.output.celsius }} °C
          </p>
          <p v-else-if="part.type === 'tool-weather' && part.state === 'output-error'">
            The weather lookup failed: {{ part.errorText }}
          </p>
          <p v-else-if="part.type === 'tool-weather'">Checking the weather…</p>
        </template>
      </li>
    </ul>
    <p v-if="chat.error" role="alert">{{ chat.error.message }}</p>
    <form @submit.prevent="send">
      <label for="prompt">Message</label>
      <input id="prompt" v-model="input" autocomplete="off">
      <button type="submit" :disabled="busy">Send</button>
      <button type="button" :disabled="chat.status !== 'streaming'" @click="chat.stop()">Stop</button>
    </form>
  </section>
</template>
```

Read the template from the inside out:

- The inner loop walks `message.parts` in order. Parts it does not name, such as the `step-start` marker that opens each step, render nothing.
- A `text` part renders its `text`.
- A `tool-weather` part whose `state` is `output-available` renders `part.output`. Because the message type came from `InferUITools`, `part.output.city` is a `string` and `part.output.celsius` a `number` as far as `vue-tsc` is concerned; misspell `celsius` and the type check fails.
- An `output-error` part shows the tool's `errorText`. Without that branch, a failed lookup would fall through to the waiting line below it and stay there.
- Any other state of the same tool, `input-streaming` or `input-available`, shows a waiting line.

In the script, `sendMessage({ text })` wraps the string in a user message with one text part. `busy` is true only while a request is in flight. Testing `chat.status !== 'ready'` instead looks the same but is not: after a failed request the status stays `'error'`, so Send would stay disabled until the page reloads. Sending again clears `chat.error`.

The key never reaches this file. `runtimeConfig.openaiApiKey` is server-only because it is not under `public`, the route runs in Nitro, and the browser only ever sees the stream.

## Upgrade a version 4 chat

The migration guide ships a codemod that handles part of the upgrade:

```
npx @ai-sdk/codemod upgrade
```

Then check these by hand:

- Templates that read `message.content` switch to the `parts` loop above.
- Tool renderers that checked `toolInvocation.state === 'result'` now check `part.state === 'output-available'` on a `tool-<name>` part, and read `part.input` and `part.output`.
- In the route, `maxSteps: 5` becomes `stopWhen: stepCountIs(5)`, and `toDataStreamResponse()` becomes `toUIMessageStreamResponse()`.
- In Vue, the `useChat` call becomes `new Chat()`, the `input` it used to own becomes your own `ref`, `append` becomes `sendMessage`, `reload` becomes `regenerate`, and `isLoading` becomes `status`.
- If you persisted messages, persist `UIMessage` objects from now on and pass `originalMessages` to `toUIMessageStreamResponse()`. With it, the response reuses the existing message IDs; without it, new IDs can make an assistant message appear twice.

Upgrade when you next touch the chat feature rather than as a separate chore; the parts model and the typed tools are worth the afternoon.

**Check it stuck**3 questions from the Streaming AI responses quiz.

[**Practice now**](https://vuejs.school/learn/ai/streaming-ai-responses?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**Streaming AI responses: fetch, ReadableStream and the AI SDK**](https://vuejs.school/community/blog/streaming-ai-responses-fetch-readablestream-and-the-ai-sdk) [Tutorial · 8 min read**Authentication basics: tokens, cookies and where to keep them**](https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-08-07","description":"AI SDK 5 splits UI messages from model messages, replaces Vue's useChat with a Chat class and types every tool part. Build a Nuxt 4 chat page with it.","name":"AI SDK 5 with Vue: the Chat class, streamText and typed messages","url":"https://vuejs.school/community/blog/ai-sdk-5-with-vue","breadcrumb":{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/ai-sdk-5-with-vue"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-08-07","description":"AI SDK 5 splits UI messages from model messages, replaces Vue's useChat with a Chat class and types every tool part. Build a Nuxt 4 chat page with it.","headline":"AI SDK 5 with Vue: the Chat class, streamText and typed messages","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcw%2Cdescription_~QUkgU0RLIDUgc3BsaXRzIFVJIG1lc3NhZ2VzIGZyb20gbW9kZWwgbWVzc2FnZXMsIHJlcGxhY2VzIFZ1ZSdzIHVzZUNoYXQgd2l0aCBhIENoYXQgY2xhc3MgYW5kIHR5cGVzIGV2ZXJ5IHRvb2wgcGFydC4gQnVpbGQgYSBOdXh0IDQgY2hhdCBwYWdlIHdpdGggaXQu%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA2IG1pbiByZWFk%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9haS1zZGstNS13aXRoLXZ1ZSI%2Cs_2GHVNOn2zWmnh8RU.png","@type":["Article","BlogPosting"],"articleSection":["Release notes"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/ai-sdk-5-with-vue#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"AI SDK 5 with Vue: the Chat class, streamText and typed messages","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcw%2Cdescription_~QUkgU0RLIDUgc3BsaXRzIFVJIG1lc3NhZ2VzIGZyb20gbW9kZWwgbWVzc2FnZXMsIHJlcGxhY2VzIFZ1ZSdzIHVzZUNoYXQgd2l0aCBhIENoYXQgY2xhc3MgYW5kIHR5cGVzIGV2ZXJ5IHRvb2wgcGFydC4gQnVpbGQgYSBOdXh0IDQgY2hhdCBwYWdlIHdpdGggaXQu%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA2IG1pbiByZWFk%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9haS1zZGstNS13aXRoLXZ1ZSI%2Cs_2GHVNOn2zWmnh8RU.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QUkgU0RLIDUgd2l0aCBWdWU6IHRoZSBDaGF0IGNsYXNzLCBzdHJlYW1UZXh0IGFuZCB0eXBlZCBtZXNzYWdlcw%2Cdescription_~QUkgU0RLIDUgc3BsaXRzIFVJIG1lc3NhZ2VzIGZyb20gbW9kZWwgbWVzc2FnZXMsIHJlcGxhY2VzIFZ1ZSdzIHVzZUNoYXQgd2l0aCBhIENoYXQgY2xhc3MgYW5kIHR5cGVzIGV2ZXJ5IHRvb2wgcGFydC4gQnVpbGQgYSBOdXh0IDQgY2hhdCBwYWdlIHdpdGggaXQu%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA2IG1pbiByZWFk%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9haS1zZGstNS13aXRoLXZ1ZSI%2Cs_2GHVNOn2zWmnh8RU.png"}]}
```