Back to templates·

How to Build an AI Chatbot with Nuxt and AI SDK

Learn how to build a full-featured AI chatbot with streaming responses, multiple models support, and a beautiful UI using Nuxt, Nuxt UI, and Vercel 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
Check out the 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

Configuration

Update your nuxt.config.ts to register the modules:

nuxt.config.ts
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:

app/assets/css/main.css
@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:

app/app.vue
<template>
  <UApp>
    <UDashboardGroup unit="rem">
      <NuxtPage />
    </UDashboardGroup>
  </UApp>
</template>

Create a .env file with your AI Gateway API key:

.env
AI_GATEWAY_API_KEY=your-api-key-here
With Vercel AI Gateway, you don't need individual API keys for OpenAI, Anthropic, or Google. The AI Gateway provides a unified API to access hundreds of models through a single endpoint.

Setting up the database

NuxtHub provides a zero-config database powered by Drizzle ORM. Here is the schema for the chat application:

server/db/schema.ts
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
Migrations are automatically applied when you start the development server with 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:

server/api/chats.post.ts
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:

server/api/chats/[id].post.ts
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 use
  • instructions: Guidance that shapes the AI's behavior
  • messages: 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:

server/api/chats/[id].get.ts
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:

app/pages/index.vue
<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 status is set to streaming
  • 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):

app/components/chat/Markdown.ts
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:

app/assets/css/main.css
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.

app/pages/chat/[id].vue
<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:

server/api/chats.get.ts
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:

app/components/ChatsHistory.vue
<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

app/pages/index.vue
<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

app/pages/chat/[id].vue
<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:

app/composables/useModels.ts
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:

app/components/ModelSelect.vue
<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:

app/pages/chat/[id].vue
<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

Updating the endpoint

Add the tools to streamText in the chat endpoint:

server/api/chats/[id].post.ts
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:

app/pages/chat/[id].vue
<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:

app/pages/chat/[id].vue
<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:

app/pages/chat/[id].vue
<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.

The file parts are stored in the database with the rest of the message. For large files, upload them to a storage like NuxtHub Blob and send their URL instead.

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).
On Vercel you don't need to manually add AI_GATEWAY_API_KEY. Vercel handles the gateway configuration for deployments. Keep using .env locally for development.
Learn more about setting up AI Gateway in the Vercel AI Gateway documentation.

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:

We're excited to see what you'll build!