How to Build an AI Chatbot with Nuxt and AI SDK
What we're building
By the end of this tutorial, you'll have a fully functional AI chatbot with:
- Streaming responses that appear in real-time as the AI generates them
- A beautiful chat interface built with Nuxt UI's purpose-built chat components
- Markdown rendering for rich AI responses with code highlighting
- Multi-model support allowing users to switch between OpenAI, Anthropic, and Google models
- Server-side AI integration using Nitro API routes and the AI SDK
- Tools with web search, an MCP server and user approval
- Queued messages so users can keep typing while a response streams
- File attachments to send images and PDFs to the model
Nuxt and Vue AI Chat templates on GitHub for production-ready implementations with authentication, database persistence, and more.Prerequisites
Before we start, make sure you have:
- Node.js 22.19+ or 24.11+ installed
- A Vercel AI Gateway API key (provides access to multiple AI providers through a single endpoint)
Project setup
Start by creating a new Nuxt project:
npx nuxi@latest init nuxt-ai-chat
cd nuxt-ai-chat
Installing dependencies
Install Nuxt UI and the AI-specific dependencies:
pnpm add @nuxt/ui tailwindcss @comark/nuxt @shikijs/langs @nuxthub/core drizzle-orm drizzle-kit @libsql/client ai @ai-sdk/vue zod
yarn add @nuxt/ui tailwindcss @comark/nuxt @shikijs/langs @nuxthub/core drizzle-orm drizzle-kit @libsql/client ai @ai-sdk/vue zod
npm install @nuxt/ui tailwindcss @comark/nuxt @shikijs/langs @nuxthub/core drizzle-orm drizzle-kit @libsql/client ai @ai-sdk/vue zod
bun add @nuxt/ui tailwindcss @comark/nuxt @shikijs/langs @nuxthub/core drizzle-orm drizzle-kit @libsql/client ai @ai-sdk/vue zod
Configuration
Update your nuxt.config.ts to register the modules:
export default defineNuxtConfig({
modules: [
'@nuxt/ui',
'@comark/nuxt',
'@nuxthub/core'
],
hub: {
db: 'sqlite'
},
css: ['~/assets/css/main.css'],
})
@comark/nuxt automatically enables Nuxt UI's prose components, so Markdown rendered by Comark is styled to match your theme.Create the main CSS file to import Tailwind CSS and Nuxt UI:
@import "tailwindcss";
@import "@nuxt/ui";
Setting up the app
Nuxt UI requires wrapping your app with UApp for modals, toasts, and overlays to work properly:
<template>
<UApp>
<UDashboardGroup unit="rem">
<NuxtPage />
</UDashboardGroup>
</UApp>
</template>
Create a .env file with your AI Gateway API key:
AI_GATEWAY_API_KEY=your-api-key-here
Setting up the database
NuxtHub provides a zero-config database powered by Drizzle ORM. Here is the schema for the chat application:
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'
import { relations } from 'drizzle-orm'
export const chats = sqliteTable('chats', {
id: text().primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text(),
createdAt: integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date())
})
export const chatsRelations = relations(chats, ({ many }) => ({
messages: many(messages)
}))
export const messages = sqliteTable('messages', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
parts: text('parts', { mode: 'json' }),
createdAt: integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}, table => [
index('messages_chat_id_idx').on(table.chatId)
])
export const messagesRelations = relations(messages, ({ one }) => ({
chat: one(chats, {
fields: [messages.chatId],
references: [chats.id]
})
}))
Generate the database migrations from your schema:
npx nuxt db generate
npx nuxt dev. NuxtHub uses SQLite locally, so no external database is required during development.Building the backend
This section covers integrating AI on the server. The following API endpoints handle chat creation, AI streaming, and data persistence using Nitro.
Creating a chat
First, create the endpoint that initializes a new chat and saves the first message to the database. This uses the UIMessage type from the AI SDK:
import { defineEventHandler, readValidatedBody } from 'h3'
import type { UIMessage } from 'ai'
import { db, schema } from 'hub:db'
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const { message } = await readValidatedBody(event, z.object({
message: z.custom<UIMessage>()
}).parse)
// Create a new chat
const [chat] = await db.insert(schema.chats).values({}).returning()
// Save the first user message
await db.insert(schema.messages).values({
chatId: chat.id,
role: 'user',
parts: message.parts
})
return chat
})
Streaming AI responses
Next, create the endpoint that handles the AI conversation. This endpoint uses streamText, createUIMessageStream, and createUIMessageStreamResponse from the AI SDK:
import { createError, defineEventHandler, getValidatedRouterParams, readValidatedBody } from 'h3'
import { eq } from 'drizzle-orm'
import { db, schema } from 'hub:db'
import { z } from 'zod'
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
streamText,
toUIMessageStream
} from 'ai'
import type { UIMessage } from 'ai'
const DEFAULT_MODEL = 'anthropic/claude-haiku-4.5'
const MODELS = [
{ value: 'openai/gpt-5-nano', label: 'GPT-5 Nano' },
{ value: 'anthropic/claude-haiku-4.5', label: 'Claude Haiku 4.5' },
{ value: 'google/gemini-3-flash', label: 'Gemini 3 Flash' }
]
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string()
}).parse)
const { model = DEFAULT_MODEL, messages } = await readValidatedBody(event, z.object({
model: z.string().refine(value => MODELS.some(m => m.value === value), {
message: 'Invalid model'
}).optional(),
messages: z.array(z.custom<UIMessage>())
}).parse)
// Fetch the chat from the database
const chat = await db.query.chats.findFirst({
where: (chat, { eq }) => eq(chat.id, id as string)
})
if (!chat) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found' })
}
// Generate a title for the chat if it doesn't have one
if (!chat.title) {
const { text: title } = await generateText({
model: DEFAULT_MODEL,
instructions: `Generate a short title (max 30 characters) based on the user's message. No quotes or punctuation.`,
prompt: JSON.stringify(messages[0])
})
await db.update(schema.chats).set({ title }).where(eq(schema.chats.id, id))
}
// Save the user message if it's a follow-up
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user' && messages.length > 1) {
await db.insert(schema.messages).values({
chatId: id,
role: 'user',
parts: lastMessage.parts
})
}
// Create the streaming response
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const result = streamText({
model,
instructions: `You are a helpful AI assistant. Be concise and friendly.`,
messages: await convertToModelMessages(messages),
providerOptions: {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: 2048
}
},
google: {
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'low'
}
},
openai: {
reasoningEffort: 'low',
reasoningSummary: 'detailed'
}
}
})
// Notify the client that a title was generated
if (!chat.title) {
writer.write({
type: 'data-chat-title',
data: { message: 'Title generated' },
transient: true
})
}
writer.merge(toUIMessageStream({ stream: result.stream }))
},
onEnd: async ({ messages }) => {
// Save the assistant's response to the database
await db.insert(schema.messages).values(messages.map(message => ({
chatId: chat.id,
role: message.role as 'user' | 'assistant',
parts: message.parts
})))
}
})
return createUIMessageStreamResponse({ stream })
})
Here's what each part does:
AI Gateway
Thanks to Vercel AI Gateway, we can use any AI model supported by the gateway just by specifying the model name.
Automatic Title Generation
When a chat doesn't have a title yet, we use generateText to create one based on the first message. This provides a better UX by showing meaningful titles in the chat history instead of "Untitled".
Streaming with streamText
The streamText function generates a streaming response from the AI model. Key options include:
model: The AI model to useinstructions: Guidance that shapes the AI's behaviormessages: The conversation history
Reasoning
The providerOptions enable reasoning for each provider (Anthropic, Google, OpenAI). The model's thinking streams to the client as reasoning parts, which we render with UChatReasoning.
UIMessageStream
The createUIMessageStream and createUIMessageStreamResponse functions create a stream that the AI SDK client can consume. The response streams chunks as they're generated, creating the real-time typing effect.
The writer.write() method allows sending custom data events to the client (like data-chat-title), while onEnd is called when streaming completes, perfect for persisting the assistant's response.
Fetching a chat
Add an endpoint to fetch existing chat data from your database:
import { createError, defineEventHandler, getValidatedRouterParams } from 'h3'
import { asc, eq } from 'drizzle-orm'
import { db, schema } from 'hub:db'
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string()
}).parse)
const chat = await db.query.chats.findFirst({
where: (eq(schema.chats.id, id)),
with: {
messages: {
orderBy: () => asc(schema.messages.createdAt)
}
}
})
if (!chat) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found' })
}
return chat
})
Wiring up the UI
Nuxt UI provides purpose-built components for AI chat interfaces: UChatPrompt for the input area and UChatMessages for displaying the conversation.
Creating the home page
The home page is where users start a new conversation. The UChatPrompt component provides a textarea with auto-resize, keyboard shortcuts, and a submit button:
<script setup lang="ts">
const input = ref('')
const loading = ref(false)
async function createChat() {
if (!input.value.trim()) return
loading.value = true
// Create a new chat on the server
const chat = await $fetch('/api/chats', {
method: 'POST',
body: {
message: {
role: 'user',
parts: [{ type: 'text', text: input.value }]
}
}
})
// Navigate to the chat page
navigateTo(`/chat/${chat.id}`)
}
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #body>
<UContainer class="min-h-dvh flex flex-col justify-center gap-6 py-8">
<h1 class="text-3xl sm:text-4xl text-highlighted font-bold">
How can I help you today?
</h1>
<UChatPrompt
v-model="input"
:status="loading ? 'streaming' : 'ready'"
variant="subtle"
placeholder="Ask me anything..."
@submit="createChat"
>
<UChatPromptSubmit color="neutral" />
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
The UChatPrompt component automatically handles:
- Form submission when pressing ↵
- Auto-resizing as you type
- A loading state when
statusis set tostreaming - Focus management and keyboard shortcuts
Setting up Markdown rendering
AI models often respond with Markdown formatting (code blocks, lists, bold text, etc.). Before building the chat page, create a custom Comark component that will handle streaming Markdown rendering. Using defineMarkdownComponent, you can enable the shiki plugin for syntax highlighting in code blocks and register additional Shiki languages beyond the defaults (TypeScript, JavaScript, Vue, Shell, JSON, YAML, Markdown):
import shiki from '@comark/nuxt/plugins/shiki'
import python from '@shikijs/langs/python'
import sql from '@shikijs/langs/sql'
import go from '@shikijs/langs/go'
import rust from '@shikijs/langs/rust'
export default defineMarkdownComponent({
name: 'ChatMarkdown',
plugins: [
shiki({
languages: [python, sql, go, rust]
})
],
class: '*:first:mt-0 *:last:mb-0'
})
This creates a <ChatMarkdown> component we'll use in the chat page to render assistant messages and reasoning content.
Since Comark uses Shiki with dual themes, you need to add the following CSS to your stylesheet for dark mode support:
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;
}
Creating the chat page
The chat page is where the actual conversation happens. It integrates the AI SDK's useChat composable and DefaultChatTransport for real-time streaming.
<script setup lang="ts">
import { DefaultChatTransport, isReasoningUIPart, isTextUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
// Fetch existing chat data
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
// Initialize the useChat composable from AI SDK
const { messages, status, error, sendMessage, regenerate, stop } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`
}),
onData(dataPart) {
// Refresh the chat list when a title is generated
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
sendMessage({ text: input.value })
input.value = ''
}
}
// Auto-generate response for first message
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:status="status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
Here's a breakdown of the key parts:
The useChat composable
The useChat composable from @ai-sdk/vue manages the entire conversation state. It handles:
- Message history with
messages - Connection status with
status(ready,submitted,streaming,error) - Sending messages with
sendMessage() - Stopping generation with
stop() - Regenerating responses with
regenerate()
The onData callback receives custom data events from the server (like data-chat-title), so you can react to server-side events during streaming.
UChatMessages Component
The UChatMessages component is purpose-built for AI chatbots with:
- Auto-scroll to bottom on load
- Continuous scrolling as messages stream in
- A loading indicator while the assistant processes
- An "Auto scroll" button when scrolled up
Rendering Message Parts
The #part slot is called once for each part of a message. We use AI SDK helpers like isTextUIPart and isReasoningUIPart to render assistant text with the <ChatMarkdown> component we created earlier and reasoning content with UChatReasoning. Any part the slot doesn't render falls back to the default output, so user messages are displayed as plain text without extra code. The isPartStreaming utility from @nuxt/ui/utils/ai detects if a part is currently being streamed, and passing it the slot's streaming prop makes it stop as soon as the response is stopped.
UChatPromptSubmit Component
The UChatPromptSubmit component adapts based on the chat status:
- Shows a send button when ready
- Shows a stop button while streaming
- Shows a reload button after an error
Adding chat history
This section adds a dropdown menu to list previous chats and navigate between them.
Listing chats API
First, create an endpoint to fetch all chats:
import { defineEventHandler } from 'h3'
import { db, schema } from 'hub:db'
import { desc } from 'drizzle-orm'
export default defineEventHandler(async () => {
return await db.query.chats.findMany({
orderBy: () => desc(schema.chats.createdAt)
})
})
Building the chats history dropdown
The component uses UDropdownMenu with a UButton as trigger. Use useFetch with a key to fetch and cache the chat list:
<script setup lang="ts">
const route = useRoute()
const { data: chats } = await useFetch('/api/chats', {
key: 'chats',
default: () => []
})
const items = computed(() => [
{
label: 'New chat',
to: '/',
icon: 'i-lucide-plus-square',
active: route.name === 'index'
},
...chats.value.map(chat => ({
label: chat.title || 'Untitled',
to: `/chat/${chat.id}`,
active: route.params.id === chat.id
}))
])
</script>
<template>
<UDropdownMenu :items="items" class="m-2">
<UButton
icon="i-lucide-messages-square"
variant="ghost"
label="Chats History"
color="neutral"
class="w-fit"
/>
</UDropdownMenu>
</template>
Integrating history in the home page
<script setup lang="ts">
const input = ref('')
const loading = ref(false)
async function createChat() {
if (!input.value.trim()) return
loading.value = true
// Create a new chat on the server
const chat = await $fetch('/api/chats', {
method: 'POST',
body: {
message: {
role: 'user',
parts: [{ type: 'text', text: input.value }]
}
}
})
// Navigate to the chat page
navigateTo(`/chat/${chat.id}`)
}
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col justify-center gap-6 py-8">
<h1 class="text-3xl sm:text-4xl text-highlighted font-bold">
How can I help you today?
</h1>
<UChatPrompt
v-model="input"
:status="loading ? 'streaming' : 'ready'"
variant="subtle"
placeholder="Ask me anything..."
@submit="createChat"
>
<UChatPromptSubmit color="neutral" />
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
Integrating history in the chat page
<script setup lang="ts">
import { DefaultChatTransport, isReasoningUIPart, isTextUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
// Fetch existing chat data
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
// Initialize the useChat composable from AI SDK
const { messages, status, error, sendMessage, regenerate, stop } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`
}),
onData(dataPart) {
// Refresh the chat list when a title is generated
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
sendMessage({ text: input.value })
input.value = ''
}
}
// Auto-generate response for first message
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:status="status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
The refreshNuxtData('chats') call in the chat page's onData callback (as shown earlier) ensures the chat list updates automatically when a new title is generated.
Adding multi-model support
One of the benefits of using AI Gateway is the ability to switch between models seamlessly. This section adds a model selector to the chat.
Creating a models composable
Define the available models and persist the user's selection using useCookie:
export function useModels() {
const models = [
{ label: 'GPT-5 Nano', value: 'openai/gpt-5-nano', icon: 'i-simple-icons-openai' },
{ label: 'Claude Haiku 4.5', value: 'anthropic/claude-haiku-4.5', icon: 'i-simple-icons-anthropic' },
{ label: 'Gemini 3 Flash', value: 'google/gemini-3-flash', icon: 'i-simple-icons-google' }
]
const model = useCookie<string>('ai-model', {
default: () => 'anthropic/claude-haiku-4.5'
})
return {
models,
model
}
}
Building the model selector
Create a USelectMenu component that displays the available models:
<script setup lang="ts">
const model = defineModel<string>({ required: true })
const { models } = useModels()
const selectedModel = computed(() =>
models.find(m => m.value === model.value)
)
</script>
<template>
<USelectMenu
v-model="model"
:items="models"
:icon="selectedModel?.icon"
variant="ghost"
value-key="value"
/>
</template>
Integrating with the chat
Update the chat page to include the model selector and pass the selected model to the server:
<script setup lang="ts">
import { DefaultChatTransport, isReasoningUIPart, isTextUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
const { model } = useModels()
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
const { messages, status, error, sendMessage, regenerate, stop } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`,
body: () => ({
model: model.value // Read on every request so a model change applies
})
}),
onData(dataPart) {
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
sendMessage({ text: input.value })
input.value = ''
}
}
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:status="status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
>
<template #footer>
<ModelSelect v-model="model" />
</template>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
Adding tools
Tools let the model search the web, call your own functions or use an MCP server before it answers. Their calls reach the client as tool parts, which we render with the UChatTool component.
Installing the providers
Web search tools come from the provider packages and MCP tools from @ai-sdk/mcp:
pnpm add @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mcp
yarn add @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mcp
npm install @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mcp
bun add @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mcp
Updating the endpoint
Add the tools to streamText in the chat endpoint:
import { createError, defineEventHandler, getValidatedRouterParams, readValidatedBody } from 'h3'
import { eq } from 'drizzle-orm'
import { db, schema } from 'hub:db'
import { z } from 'zod'
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
isStepCount,
streamText,
toUIMessageStream,
tool
} from 'ai'
import type { UIMessage } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
import { createMCPClient } from '@ai-sdk/mcp'
const DEFAULT_MODEL = 'anthropic/claude-haiku-4.5'
const MODELS = [
{ value: 'openai/gpt-5-nano', label: 'GPT-5 Nano' },
{ value: 'anthropic/claude-haiku-4.5', label: 'Claude Haiku 4.5' },
{ value: 'google/gemini-3-flash', label: 'Gemini 3 Flash' }
]
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string()
}).parse)
const { model = DEFAULT_MODEL, messages } = await readValidatedBody(event, z.object({
model: z.string().refine(value => MODELS.some(m => m.value === value), {
message: 'Invalid model'
}).optional(),
messages: z.array(z.custom<UIMessage>())
}).parse)
// Fetch the chat from the database
const chat = await db.query.chats.findFirst({
where: (chat, { eq }) => eq(chat.id, id as string)
})
if (!chat) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found' })
}
// Generate a title for the chat if it doesn't have one
if (!chat.title) {
const { text: title } = await generateText({
model: DEFAULT_MODEL,
instructions: `Generate a short title (max 30 characters) based on the user's message. No quotes or punctuation.`,
prompt: JSON.stringify(messages[0])
})
await db.update(schema.chats).set({ title }).where(eq(schema.chats.id, id))
}
// Save the user message if it's a follow-up
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user' && messages.length > 1) {
await db.insert(schema.messages).values({
chatId: id,
role: 'user',
parts: lastMessage.parts
})
}
// Web search is a provider tool, Google can't combine it with other tools yet
const webSearch = model.startsWith('anthropic/')
? anthropic.tools.webSearch_20250305()
: model.startsWith('openai/')
? openai.tools.webSearch()
: undefined
// Connect to the Nuxt UI MCP server to search its documentation, the chat works without it
const mcpClient = await createMCPClient({
transport: { type: 'http', url: 'https://ui.nuxt.com/mcp' }
}).catch(() => undefined)
const mcpTools = await mcpClient?.tools().catch(async () => {
await mcpClient.close()
return {}
}) ?? {}
// Create the streaming response
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
const result = streamText({
model,
instructions: `You are a helpful AI assistant. Be concise and friendly.`,
messages: await convertToModelMessages(messages),
tools: {
...mcpTools,
...(webSearch && { web_search: webSearch }),
rename_chat: tool({
description: 'Rename the current chat',
inputSchema: z.object({
title: z.string().trim().min(1).max(30).describe('The new title')
}),
execute: async ({ title }) => {
await db.update(schema.chats).set({ title }).where(eq(schema.chats.id, id))
// Refresh the chat list like for a generated title
writer.write({
type: 'data-chat-title',
data: { message: 'Chat renamed' },
transient: true
})
return { title }
}
})
},
toolApproval: {
rename_chat: 'user-approval'
},
stopWhen: isStepCount(5),
providerOptions: {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: 2048
}
},
google: {
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'low'
}
},
openai: {
reasoningEffort: 'low',
reasoningSummary: 'detailed'
}
}
})
// Notify the client that a title was generated
if (!chat.title) {
writer.write({
type: 'data-chat-title',
data: { message: 'Title generated' },
transient: true
})
}
writer.merge(toUIMessageStream({ stream: result.stream }))
},
onEnd: async ({ responseMessage }) => {
// Called once the stream ends, whether it finished, failed or was stopped
await mcpClient?.close()
// A response continued after an approval keeps its id, so update it in place
await db.insert(schema.messages).values({
id: responseMessage.id,
chatId: chat.id,
role: 'assistant',
parts: responseMessage.parts
}).onConflictDoUpdate({
target: schema.messages.id,
set: { parts: responseMessage.parts }
})
}
})
return createUIMessageStreamResponse({ stream })
})
Here's what each tool does:
Web search
Anthropic and OpenAI provide a built-in web search tool (Anthropic, OpenAI), so we pick the one matching the selected model. Google has its own Google Search tool, but the AI SDK can't combine it with other tools yet.
MCP tools
createMCPClient connects to a Model Context Protocol server and turns its tools into AI SDK tools. Here the Nuxt UI MCP server lets the model search the Nuxt UI documentation. If the server can't be reached, the chat keeps working without its tools. The client is closed if listing its tools fails, and in the onEnd callback of createUIMessageStream, which runs once the stream ends, whether it finished, failed or was stopped.
Tool approval
The rename_chat tool updates the chat title in the database. The toolApproval option pauses it in the approval-requested state until the user approves or denies it. The approved call runs in a new request that continues the same assistant message, which is why originalMessages is passed to createUIMessageStream and the response is now updated in place by its id.
Multiple steps
stopWhen: isStepCount(5) lets the model call tools and keep answering with their results, for up to 5 steps.
Rendering tool calls
Render the tool parts in the #part slot and add the approval actions:
<script setup lang="ts">
import { DefaultChatTransport, getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
const { model } = useModels()
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
const { messages, status, error, sendMessage, regenerate, stop, addToolApprovalResponse } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`,
body: () => ({
model: model.value // Read on every request so a model change applies
})
}),
// Send the approval responses back once every pending tool has one
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onData(dataPart) {
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
sendMessage({ text: input.value })
input.value = ''
}
}
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part, streaming)"
:error="part.state === 'output-error'"
: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"
/>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
:status="status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
>
<template #footer>
<ModelSelect v-model="model" />
</template>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
The UChatTool component shows the tool name and a shimmer while it runs. The isToolStreaming utility from @nuxt/ui/utils/ai detects if a tool call is still running. When a tool waits for approval, the actions prop displays the approve and deny buttons, which answer with addToolApprovalResponse. The sendAutomaticallyWhen option then sends the responses back to the server so the conversation continues.
Queuing messages Soon
The AI SDK doesn't queue messages: calling sendMessage during a response starts a second request next to the current one. With status, UChatPrompt ignores submits while the chat is submitted or streaming. Bind v-model:queue to keep them instead, then send the next one once the chat is ready again:
<script setup lang="ts">
import type { ChatPromptQueueItem } from '@nuxt/ui'
import { DefaultChatTransport, getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
const { model } = useModels()
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
const queue = ref<ChatPromptQueueItem[]>([])
const { messages, status, error, sendMessage, regenerate, stop, addToolApprovalResponse } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`,
body: () => ({
model: model.value // Read on every request so a model change applies
})
}),
// Send the approval responses back once every pending tool has one
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onData(dataPart) {
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
// A response waiting for a tool approval is `ready` too, a new message would leave it unanswered
const awaitingApproval = computed(() => messages.value.at(-1)?.parts.some(part => isToolUIPart(part) && part.state === 'approval-requested') ?? false)
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
sendMessage({ text: input.value })
input.value = ''
}
}
function sendNext() {
if (awaitingApproval.value) {
return
}
const item = queue.value.shift()
if (item) {
sendMessage({ text: item.text })
}
}
watch(status, (value) => {
if (value === 'ready') {
sendNext()
}
})
// The message picked to be sent now is at the front of the queue
function onSend() {
if (status.value === 'submitted' || status.value === 'streaming') {
stop()
} else {
sendNext()
}
}
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part, streaming)"
:error="part.state === 'output-error'"
: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"
/>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
v-model:queue="queue"
:status="awaitingApproval ? 'submitted' : status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
@send="onSend"
>
<template #footer>
<ModelSelect v-model="model" />
</template>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
The prompt lists the queued messages in a panel above it, where users can send one now, put it back in the prompt to edit it, or remove it. Picking one to send now emits send with that message at the front of the queue, so onSend stops the current response and the status watcher sends it. A response waiting for a tool approval is ready too, so the prompt gets the submitted status until the user answers, which keeps new messages in the queue, and sendNext waits as well. After an error, the prompt is disabled and the queue waits until the user reloads the failed response.
Attaching files Soon
Bind v-model:files on the prompt to let users attach images and PDFs. They can paste them, drop them on the prompt or pick them with the button in the #footer slot:
<script setup lang="ts">
import type { ChatPromptQueueItem } from '@nuxt/ui'
import { DefaultChatTransport, getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming, toFileUIParts } from '@nuxt/ui/utils/ai'
const route = useRoute()
const toast = useToast()
const { model } = useModels()
const { data: chatData } = await useFetch(`/api/chats/${route.params.id}`)
if (!chatData.value) {
throw createError({ statusCode: 404, statusMessage: 'Chat not found', fatal: true })
}
const input = ref('')
const queue = ref<ChatPromptQueueItem[]>([])
const files = ref<File[]>([])
// Reading the files is async, the prompt queues new messages meanwhile
const reading = ref(false)
const { messages, status, error, sendMessage, regenerate, stop, addToolApprovalResponse } = useChat({
id: chatData.value.id,
messages: chatData.value.messages,
transport: new DefaultChatTransport({
api: `/api/chats/${chatData.value.id}`,
body: () => ({
model: model.value // Read on every request so a model change applies
})
}),
// Send the approval responses back once every pending tool has one
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onData(dataPart) {
if (dataPart.type === 'data-chat-title') {
refreshNuxtData('chats')
}
},
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error'
})
}
})
// A response waiting for a tool approval is `ready` too, a new message would leave it unanswered
const awaitingApproval = computed(() => messages.value.at(-1)?.parts.some(part => isToolUIPart(part) && part.state === 'approval-requested') ?? false)
async function send({ text, files }: ChatPromptQueueItem) {
reading.value = true
try {
const parts = await toFileUIParts(files ?? [])
// An empty text part is rejected by some providers, send the files alone
sendMessage(text.trim() ? { text, files: parts } : { files: parts })
} catch (error) {
toast.add({
title: 'Error',
description: (error as Error).message,
color: 'error'
})
} finally {
reading.value = false
}
// Nothing was sent if the files couldn't be read, move on to the queued messages
if (status.value === 'ready') {
sendNext()
}
}
function handleSubmit(e: Event) {
e.preventDefault()
send({ text: input.value, files: files.value })
input.value = ''
files.value = []
}
function sendNext() {
if (reading.value || awaitingApproval.value) {
return
}
const item = queue.value.shift()
if (item) {
send(item)
}
}
watch(status, (value) => {
if (value === 'ready') {
sendNext()
}
})
// The message picked to be sent now is at the front of the queue
function onSend() {
if (status.value === 'submitted' || status.value === 'streaming') {
stop()
} else {
sendNext()
}
}
onMounted(() => {
if (chatData.value?.messages.length === 1) {
regenerate()
}
})
</script>
<template>
<UDashboardPanel :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<ChatsHistory />
</template>
<template #body>
<UContainer class="min-h-dvh flex flex-col py-4 sm:py-6">
<UChatMessages
:messages="messages"
:status="status"
should-auto-scroll
class="flex-1"
>
<template #files="{ parts }">
<UChatAttachment
v-for="(part, index) in parts"
:key="index"
:name="part.filename"
:type="part.mediaType"
:url="part.url"
/>
</template>
<template #part="{ part, message, streaming }">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part, streaming)"
>
<ChatMarkdown
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</UChatReasoning>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolName(part)"
:streaming="isToolStreaming(part, streaming)"
:error="part.state === 'output-error'"
: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"
/>
<ChatMarkdown
v-else-if="isTextUIPart(part) && message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part, streaming)"
/>
</template>
</UChatMessages>
<UChatPrompt
v-model="input"
v-model:queue="queue"
v-model:files="files"
accept="image/*,application/pdf"
:status="reading || awaitingApproval ? 'submitted' : status"
:error="error"
variant="subtle"
class="sticky bottom-0"
@submit="handleSubmit"
@send="onSend"
>
<template #footer="{ open }">
<UButton
icon="i-lucide-paperclip"
color="neutral"
variant="ghost"
aria-label="Attach files"
@click="open"
/>
<ModelSelect v-model="model" />
</template>
<UChatPromptSubmit
:status="status"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</UChatPrompt>
</UContainer>
</template>
</UDashboardPanel>
</template>
Each file shows above the textarea as a UChatAttachment until the message is sent. The toFileUIParts utility from @nuxt/ui/utils/ai reads the files as data URLs, which sendMessage sends as file parts the models can read. A queued message keeps its files, so send handles both the submitted and the queued ones. While the files are read, the prompt gets the submitted status, so a message sent meanwhile goes to the queue instead of starting a second request. In the conversation, the #files slot of UChatMessages renders the file parts of each message.
Going further
You now have a working AI chatbot with database persistence! To take it further, consider adding:
User Authentication
Add authentication with nuxt-auth-utils to let users access their chat history across devices and keep conversations private.
Deploying to Vercel
Deploy your chatbot to Vercel with zero configuration:
npx vercel deploy
Then, in the Vercel dashboard:
- Enable AI Gateway and add credits so requests can be processed.
- Add a Turso database from the Vercel Marketplace and connect it to your project (it will provision the database and add the required environment variables automatically).
AI_GATEWAY_API_KEY. Vercel handles the gateway configuration for deployments. Keep using .env locally for development.Conclusion
You've built a complete AI chatbot with:
- A complete chat interface using Nuxt UI components
- Real-time streaming responses with the AI SDK
- Streaming Markdown rendering with Comark for rich content display
- Multi-model support via AI Gateway
- Tools with web search, MCP and user approval
- Queued messages and file attachments in the prompt
- Database persistence with SQLite (local) / Turso (production) and Drizzle ORM
The combination of Nuxt's full-stack capabilities, Nuxt UI's purpose-built chat components, a local SQLite dev database with a production Turso database, and the AI SDK's streaming infrastructure makes building AI applications straightforward and enjoyable.
Resources:
- Nuxt UI Chat Components
- NuxtHub Database
- AI SDK Documentation
- AI Gateway Documentation
- Nuxt AI Chat Template
- Vue AI Chat Template
We're excited to see what you'll build!