n4nAI

Building a ChatGPT-style UI in Vue 3 and Pinia

Step-by-step tutorial for building a streaming ChatGPT-style chat interface in Vue 3 and Pinia, with OpenAI-compatible API integration and Pinia store design.

n4n Team2 min read534 words

Audio narration

Coming soon — every post will get a voice note here.

Building a vue 3 pinia chatgpt ui from scratch forces you to confront streaming, state shape, and error boundaries early. This tutorial walks through a minimal but production-minded implementation that talks to any OpenAI-compatible /v1/chat/completions endpoint and renders token-by-token output without a heavyweight client library.

Prerequisites

  • Node 18+ and npm.
  • Vue 3 with <script setup> (Vite scaffold).
  • Pinia installed (npm i pinia).
  • An OpenAI-compatible API key, or the local mock server shown later.
  • Basic comfort with the Fetch Streams API and TypeScript.

If you have not used Pinia before, understand it as a typed, reactive store with devtools support and no prop drilling. That matters when a stream writes to state every few milliseconds.

Scaffold the Project

npm create vite@latest chat-ui -- --template vue-ts
cd chat-ui
npm i pinia

Enable Pinia in main.ts:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

createApp(App).use(createPinia()).mount('#app')

Store Design

The vue 3 pinia chatgpt ui needs a single source of truth for messages. Put it in a store so components stay dumb.

Types and State

// stores/chat.ts
import { defineStore } from 'pinia'

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  streaming?: boolean
}

export const useChatStore = defineStore('chat', {
  state: () => ({
    messages: [] as Message[],
    apiBase: import.meta.env.VITE_API_BASE ?? 'https://api.openai.com/v1',
    apiKey: import.meta.env.VITE_API_KEY ?? '',
    model: 'gpt-3.5-turbo',
    error: null as string | null,
  }),
  actions: {
    addMessage(msg: Message) {
      this.messages.push(msg)
    },
    updateLastAssistant(content: string) {
      const last = this.messages[this.messages.length - 1]
      if (last?.role === 'assistant') last.content = content
    },
    reset() {
      this.messages = []
      this.error = null
    },
  },
})

Keep the streaming flag on assistant messages. It drives the cursor UI and tells the UI whether to auto-scroll.

Streaming Composable

A stateless composable reads the store and performs the fetch. The key part is parsing Server-Sent Events over a ReadableStream.

// composables/useChatStream.ts
import { useChatStore } from '@/stores/chat'

export function useChatStream() {
  const store = useChatStore()

  async function send(prompt: string) {
    store.error = null
    const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: prompt }
    store.addMessage(userMsg)
    const assistantMsg: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      streaming: true,
    }
    store.addMessage(assistantMsg)

    const res = await fetch(`${store.apiBase}/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${store.apiKey}`,
      },
      body: JSON.stringify({
        model: store.model,
        messages: store.messages.map(({ role, content }) => ({ role, content })),
        stream: true,
      }),
    })

    if (!res.ok || !res.body) {
      store.error = `HTTP ${res.status}`
      assistantMsg.streaming = false
      return
    }

    const reader = res.body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() ?? ''
      for (const line of lines) {
        const trimmed = line.trim()
        if (!trimmed.startsWith('data:')) continue
        const data = trimmed.slice(5).trim()
        if (data === '[DONE]') continue
        try {
          const json = JSON.parse(data)
          const delta = json.choices?.[0]?.delta?.content ?? ''
          assistantMsg.content += delta
          store.updateLastAssistant(assistantMsg.content)
        } catch {
          // Ignore keep-alive comments or partial JSON
        }
      }
    }
    assistantMsg.streaming = false
  }

  return { send }
}

The buffer.split('\n') plus lines.pop() pattern handles partial lines across chunk boundaries. Do not assume each read() returns a complete SSE frame.

Components

Message List

<!-- components/MessageList.vue -->
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
const store = useChatStore()
</script>

<template>
  <div class="msg-list">
    <div v-for="m in store.messages" :key="m.id" :class="['msg', m.role]">
      <span class="role">{{ m.role }}</span>
      <span class="content">{{ m.content }}<span v-if="m.streaming">▌</span></span>
    </div>
    <div v-if="store.error" class="error">{{ store.error }}</div>
  </div>
</template>

Composer

<!-- components/Composer.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStream } from '@/composables/useChatStream'
const { send } = useChatStream()
const input = ref('')

function submit() {
  if (!input.value.trim()) return
  send(input.value)
  input.value = ''
}
</script>

<template>
  <form @submit.prevent="submit" class="composer">
    <input v-model="input" placeholder="Type a message…" />
    <button type="submit">Send</button>
  </form>
</template>

App Root

<!-- App.vue -->
<script setup lang="ts">
import MessageList from '@/components/MessageList.vue'
import Composer from '@/components/Composer.vue'
</script>

<template>
  <main class="chat">
    <MessageList />
    <Composer />
  </main>
</template>

<style>
.chat { max-width: 720px; margin: 0 auto; font-family: system-ui; }
.msg-list { display: flex; flex-direction: column; gap: 8px; padding: 16px; min-height: 60vh; }
.msg.user { align-self: flex-end; background: #e7f0ff; padding: 8px 12px; border-radius: 12px; }
.msg.assistant { align-self: flex-start; background: #f3f3f3; padding: 8px 12px; border-radius: 12px; }
.composer { display: flex; gap: 8px; padding: 16px; }
.composer input { flex: 1; padding: 8px; }
.error { color: #b00; }
</style>

Environment and Endpoint

Create .env.local:

VITE_API_BASE=https://api.openai.com/v1
VITE_API_KEY=sk-your-key

If you point the base URL at n4n.ai, you get one OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is degraded. The same useChatStream code works unchanged because the request and response shapes match the OpenAI spec.

Local Mock for Development

Avoid burning quota while building UI. Run a tiny Node server that streams fake tokens:

// mock-server.mjs
import http from 'node:http'

http.createServer((req, res) => {
  if (req.url !== '/v1/chat/completions') return res.end()
  res.writeHead(200, { 'Content-Type': 'text/event-stream' })
  const tokens = ['Hello', ' ', 'from', ' ', 'mock', ' ', 'stream.']
  let i = 0
  const timer = setInterval(() => {
    if (i >= tokens.length) {
      res.write('data: [DONE]\n\n')
      clearInterval(timer)
      res.end()
      return
    }
    res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: tokens[i++] } }] })}\n\n`)
  }, 100)
}).listen(8787)

Set VITE_API_BASE=http://localhost:8787 and skip the auth header in the composable for local dev.

Checkpoint: First Render

Run npm run dev. The page shows an empty .msg-list and a composer. Vue devtools should show the chat store with empty messages. No network calls fire until you submit.

Checkpoint: Streaming Response

Type “Hello” and submit. In the network tab, confirm a POST to /chat/completions with body containing "stream": true. The assistant bubble fills incrementally with a cursor. After completion, the Pinia state looks like:

{
  "messages": [
    { "role": "user", "content": "Hello" },
    { "role": "assistant", "content": "Hi! How can I help you today?", "streaming": false }
  ],
  "error": null
}

That is the core vue 3 pinia chatgpt ui loop.

Error Boundaries and Retry

Providers return 429 or 503 under load. Surface that in the store and offer a retry:

// inside store actions
async retryLast() {
  const lastUser = [...this.messages].reverse().find(m => m.role === 'user')
  if (!lastUser) return
  this.messages = this.messages.filter(m => m !== lastUser)
  const { send } = useChatStream()
  await send(lastUser.content)
}

Add a button in MessageList.vue when store.error is non-null. This keeps the UI usable when a single request fails.

Why Pinia Wins Here

Local component state would force you to lift streaming callbacks up through props or use provide/inject. Pinia gives a flat, inspectable messages array that any component can read. The composable stays a pure function of the store. When you later add conversation switching or persistence to IndexedDB, you extend the store, not the component tree.

Production Hardening

  • Add an AbortController to cancel in-flight streams when the user navigates away.
  • Debounce auto-scroll only while streaming is true.
  • Never ship the API key in the browser for real users; proxy through a backend or Vite dev server:
// vite.config.ts
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.openai.com/v1',
        changeOrigin: true,
        rewrite: p => p.replace(/^\/api/, ''),
        headers: { Authorization: `Bearer ${process.env.OPENAI_KEY}` },
      },
    },
  },
}

Then set VITE_API_BASE=/api.

The vue 3 pinia chatgpt ui pattern above is the same one you would extend for multi-model pickers, tool calls, or reaction streams. The store is the contract; the composable is the transport.

Tagsvuepiniachat-uitutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vue.js & nuxt llm streaming chat posts →