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
AbortControllerto cancel in-flight streams when the user navigates away. - Debounce auto-scroll only while
streamingis 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.