Core Concepts

Building Chat UI

Last updated by
Harlan Wilton
in doc: sync.

This guide shows how to build chat interfaces by combining Nuxt UI Chat components with AI Search primitives.

Component Overview

From Nuxt UI

ComponentPurpose
UChatPromptText input with submit button
UChatPromptSubmitSubmit/stop/reload button
UChatMessagesMessage list with auto-scroll
UChatMessageIndividual message display
ComponentPurpose
AiSearchPreStreamStreaming code highlighting
AiSearchToolCallTool invocation indicator
AiSearchReasoningCollapsible thinking section
AiSearchSourcesSearch result citations
AskAiInputRich ProseKit editor
AiSearchToolbarFloating search bar

Basic Chat Page

<script setup>
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'

const input = ref('')

const chat = new Chat({
  transport: new DefaultChatTransport({ api: '/api/chat' })
})

function handleSubmit(e: Event) {
  e.preventDefault()
  if (!input.value.trim()) return
  chat.sendMessage({ text: input.value })
  input.value = ''
}
</script>

<template>
  <div class="flex flex-col h-screen">
    <UChatMessages
      :messages="chat.messages"
      :status="chat.status"
      should-auto-scroll
      class="flex-1 p-4"
    />

    <UChatPrompt
      v-model="input"
      :error="chat.error"
      class="sticky bottom-0"
      @submit="handleSubmit"
    >
      <UChatPromptSubmit
        :status="chat.status"
        @stop="chat.stop()"
        @reload="chat.regenerate()"
      />
    </UChatPrompt>
  </div>
</template>

Rendering Message Parts

AI SDK v5 uses a parts-based message format. Render each part type:

<script setup>
import AiSearchPreStream from '#components'

const components = {
  pre: AiSearchPreStream
}

function getToolLabel(part: any) {
  const labels: Record<string, string> = {
    'search': 'Searching...',
    'get-page': `Read ${part.args?.path || ''}`,
  }
  return labels[part.toolName] || part.toolName
}
</script>

<template>
  <UChatMessages :messages="chat.messages" :status="chat.status">
    <template #content="{ message }">
      <template v-for="(part, index) in message.parts" :key="index">
        <!-- Reasoning/thinking -->
        <AiSearchReasoning
          v-if="part.type === 'reasoning'"
          :text="part.text"
          :is-streaming="part.state !== 'done'"
        />

        <!-- Text content with streaming code -->
        <MDCCached
          v-else-if="part.type === 'text'"
          :value="part.text"
          :cache-key="`${message.id}-${index}`"
          :components="components"
          :parser-options="{ highlight: false }"
        />

        <!-- Tool calls -->
        <AiSearchToolCall
          v-else-if="part.type === 'tool-invocation'"
          :text="getToolLabel(part)"
          :is-loading="part.state !== 'output-available'"
        />
      </template>
    </template>
  </UChatMessages>
</template>

Adding Sources

Track sources from search tool calls:

<script setup>
const sources = ref<Map<string, Source[]>>(new Map())

const chat = new Chat({
  transport: new DefaultChatTransport({ api: '/api/chat' }),
  onData(data) {
    // Capture sources from tool results
    if (data.type === 'tool-result' && data.toolName === 'search') {
      sources.value.set(chat.lastMessage?.id, data.result)
    }
  }
})
</script>

<template>
  <UChatMessages :messages="chat.messages" :status="chat.status">
    <template #content="{ message }">
      <!-- Message parts... -->

      <AiSearchSources
        v-if="sources.get(message.id)"
        :sources="sources.get(message.id)"
      />
    </template>
  </UChatMessages>
</template>

In a Slideover

Compose with Nuxt UI's USlideover:

<script setup>
const open = ref(false)
</script>

<template>
  <UButton @click="open = true">Ask AI</UButton>

  <USlideover v-model:open="open" side="right">
    <template #header>
      <span>Ask AI</span>
    </template>

    <template #body>
      <UChatMessages
        :messages="chat.messages"
        :status="chat.status"
        class="flex-1"
      >
        <!-- Content template... -->
      </UChatMessages>
    </template>

    <template #footer>
      <UChatPrompt v-model="input" @submit="handleSubmit">
        <UChatPromptSubmit :status="chat.status" />
      </UChatPrompt>
    </template>
  </USlideover>
</template>

FAQ Section

Add suggested questions when chat is empty:

<script setup>
const faq = [
  { category: 'Getting Started', items: ['How do I install?', 'What are the requirements?'] },
  { category: 'Configuration', items: ['How do I configure embeddings?', 'What providers are supported?'] }
]

function askQuestion(question: string) {
  chat.sendMessage({ text: question })
}
</script>

<template>
  <div v-if="chat.messages.length === 0" class="p-4">
    <div v-for="category in faq" :key="category.category" class="mb-4">
      <h4 class="text-xs font-medium text-muted uppercase mb-2">
        {{ category.category }}
      </h4>
      <button
        v-for="question in category.items"
        :key="question"
        class="block text-sm text-muted hover:text-highlighted py-1"
        @click="askQuestion(question)"
      >
        {{ question }}
      </button>
    </div>
  </div>

  <UChatMessages v-else :messages="chat.messages" :status="chat.status">
    <!-- Content template... -->
  </UChatMessages>
</template>

With Rich Input

Use AskAiInput for rich text editing:

<template>
  <form @submit.prevent="handleSubmit" class="border rounded-lg p-2">
    <AskAiInput placeholder="Ask anything..." />
    <div class="flex justify-end mt-2">
      <UButton type="submit" :loading="chat.status === 'streaming'">
        Send
      </UButton>
    </div>
  </form>
</template>

Floating Toolbar

Add a global search entry point:

<template>
  <div>
    <!-- Page content -->

    <AiSearchToolbar />
  </div>
</template>

The toolbar navigates to /chat?q={query} on submit.

Full Example

See the chat template for a complete implementation with:

  • Persistent chat history
  • Model selection
  • Custom tool components (Weather, Charts)
  • Dashboard layout
Did this page help you?