Chat
Nuxt UI provides a set of components designed to build AI-powered chat interfaces. They integrate seamlessly with the Vercel AI SDK for streaming responses, reasoning, tool calling, and more.
Components
| Component | Description |
|---|---|
| ChatMessages | Scrollable message list with auto-scroll and loading indicator. |
| ChatMessage | Individual message bubble with avatar, actions, and slots. |
| ChatPrompt | Enhanced textarea for submitting prompts. |
| ChatPromptSubmit | Submit button with automatic status handling. |
| ChatReasoning | Collapsible block for AI reasoning / thinking process. |
| ChatTool | Collapsible block for AI tool invocation status. |
| ChatShimmer | Text shimmer animation for streaming states. |
| ChatPalette | Layout wrapper for embedding chat in modals or drawers. |
Installation
The Chat components are designed to be used with the Vercel AI SDK, specifically the Chat class for managing chat state and streaming responses.
Install the required dependencies:
pnpm add ai @ai-sdk/gateway @ai-sdk/vue @comark/nuxt
yarn add ai @ai-sdk/gateway @ai-sdk/vue @comark/nuxt
npm install ai @ai-sdk/gateway @ai-sdk/vue @comark/nuxt
bun add ai @ai-sdk/gateway @ai-sdk/vue @comark/nuxt
Add @comark/nuxt to your modules:
export default defineNuxtConfig({
modules: [
'@nuxt/ui',
'@comark/nuxt'
]
})
@comark/nuxt provides the Markdown component used to render AI responses as streaming Markdown, it incrementally renders tokens as they arrive, avoiding the flicker and re-parsing that traditional Markdown renderers cause. It also automatically enables Nuxt UI's prose components so your content is styled to match your theme.pnpm add ai @ai-sdk/gateway @ai-sdk/vue @comark/vue
yarn add ai @ai-sdk/gateway @ai-sdk/vue @comark/vue
npm install ai @ai-sdk/gateway @ai-sdk/vue @comark/vue
bun add ai @ai-sdk/gateway @ai-sdk/vue @comark/vue
@comark/vue provides the Markdown component used to render AI responses as streaming Markdown, it incrementally renders tokens as they arrive, avoiding the flicker and re-parsing that traditional Markdown renderers cause.
To use Nuxt UI's prose components with Comark, enable the
prose option in your vite.config.ts:import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
prose: true
})
]
})
Server Setup
Create a server API endpoint to handle chat requests using streamText. You can use the Vercel AI Gateway to access AI models through a centralized endpoint:
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('anthropic/claude-sonnet-5'),
maxOutputTokens: 10000,
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages)
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
Reasoning
To enable reasoning, configure providerOptions for your provider (Anthropic, Google, OpenAI):
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('anthropic/claude-sonnet-5'),
maxOutputTokens: 10000,
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
providerOptions: {
anthropic: {
thinking: {
type: 'adaptive'
},
effort: 'low'
},
google: {
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'low'
}
},
openai: {
reasoningEffort: 'low',
reasoningSummary: 'detailed'
}
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
Web Search
Some providers offer built-in web search tools: Anthropic, Google, OpenAI.
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('anthropic/claude-sonnet-5'),
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
tools: {
web_search: anthropic.tools.webSearch_20250305({})
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { google } from '@ai-sdk/google'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('google/gemini-3-flash'),
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
tools: {
google_search: google.tools.googleSearch({})
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { openai } from '@ai-sdk/openai'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('openai/gpt-5-nano'),
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
tools: {
web_search: openai.tools.webSearch({})
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
MCP Client
Empower your chatbot with advanced tool-calling features using the Model Context Protocol (MCP) from @ai-sdk/mcp. MCP enables your AI to perform dynamic actions, such as searching your documentation or executing custom tasks, to provide more relevant and accurate responses.
To get started, install the MCP package:
npm install @ai-sdk/mcp
pnpm add @ai-sdk/mcp
yarn add @ai-sdk/mcp
Then, configure your server endpoint to use MCP tools:
import { streamText, convertToModelMessages, isStepCount, toUIMessageStream, createUIMessageStreamResponse } from 'ai'
import { createMCPClient } from '@ai-sdk/mcp'
import { gateway } from '@ai-sdk/gateway'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const httpClient = await createMCPClient({
transport: { type: 'http', url: 'https://your-app.com/mcp' }
})
try {
const tools = await httpClient.tools()
const result = streamText({
model: gateway('anthropic/claude-sonnet-5'),
maxOutputTokens: 10000,
instructions: 'You are a helpful assistant. Use your tools to search for relevant information before answering questions.',
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(6),
tools,
onEnd: async () => {
await httpClient.close()
},
onError: async (error) => {
console.error(error)
await httpClient.close()
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
} catch (error) {
// Close the MCP client if setup fails before streaming starts
await httpClient.close()
throw error
}
})
Tool Approval
Require a user confirmation before a tool runs with the toolApproval option. The tool part pauses in the approval-requested state until the user responds:
import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse, tool } from 'ai'
import { gateway } from '@ai-sdk/gateway'
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const { messages } = await readBody(event)
const result = streamText({
model: gateway('anthropic/claude-sonnet-5'),
maxOutputTokens: 10000,
instructions: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
tools: {
deleteFile: tool({
description: 'Delete a file from the project',
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => ({ deleted: path })
})
},
toolApproval: {
deleteFile: 'user-approval'
}
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
Client Setup
Use the useChat composable from @ai-sdk/vue to manage chat state and connect to your server endpoint:
<script setup lang="ts">
import { isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
import shiki from '@comark/nuxt/plugins/shiki'
const input = ref('')
const { messages, status, error, sendMessage, regenerate, stop, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onError(error) {
console.error(error)
}
})
function onSubmit() {
sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<UChatMessages
:messages="messages"
:status="status"
>
<template #content="{ message }">
<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
>
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part)"
>
<Markdown
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="[shiki()]"
class="*:first:mt-0 *:last:mb-0"
/>
</UChatReasoning>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part)"
:actions="part.state === 'approval-requested' ? [
{ label: 'Approve', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: true }) },
{ label: 'Deny', color: 'neutral', variant: 'ghost', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: false }) }
] : undefined"
/>
<template v-else-if="isTextUIPart(part)">
<Markdown
v-if="message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="[shiki()]"
class="*:first:mt-0 *:last:mb-0"
/>
<p v-else-if="message.role === 'user'" class="whitespace-pre-wrap">
{{ part.text }}
</p>
</template>
</template>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:error="error"
@submit="onSubmit"
>
<UChatPromptSubmit
:status="status"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
import { Markdown } from '@comark/vue'
import shiki from '@comark/vue/plugins/shiki'
const input = ref('')
const { messages, status, error, sendMessage, regenerate, stop, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onError(error) {
console.error(error)
}
})
function onSubmit() {
sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<UChatMessages
:messages="messages"
:status="status"
>
<template #content="{ message }">
<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
>
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part)"
>
<Markdown
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="[shiki()]"
class="*:first:mt-0 *:last:mb-0"
/>
</UChatReasoning>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part)"
:actions="part.state === 'approval-requested' ? [
{ label: 'Approve', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: true }) },
{ label: 'Deny', color: 'neutral', variant: 'ghost', onClick: () => addToolApprovalResponse({ id: part.approval.id, approved: false }) }
] : undefined"
/>
<template v-else-if="isTextUIPart(part)">
<Markdown
v-if="message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="[shiki()]"
class="*:first:mt-0 *:last:mb-0"
/>
<p v-else-if="message.role === 'user'" class="whitespace-pre-wrap">
{{ part.text }}
</p>
</template>
</template>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:error="error"
@submit="onSubmit"
>
<UChatPromptSubmit
:status="status"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</template>
defineMarkdownComponent to create a custom component instead of passing props inline each time.import shiki from '@comark/nuxt/plugins/shiki'
export default defineMarkdownComponent({
name: 'ChatMarkdown',
plugins: [shiki()],
class: '*:first:mt-0 *:last:mb-0'
})
import { defineMarkdownComponent } from '@comark/vue'
import shiki from '@comark/vue/plugins/shiki'
export default defineMarkdownComponent({
name: 'ChatMarkdown',
plugins: [shiki()],
class: '*:first:mt-0 *:last:mb-0'
})
shiki plugin, add the following CSS to your stylesheet to support dark mode:html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}