Blog/Web engineering
Streaming AI chat in Vue and Nuxt: accessible by design
How to build a Vue or Nuxt AI chat UI: fetch streams, abort and retry, safe markdown rendering, aria-live announcements, keyboard support and reduced motion.
Balázs Csorba··11 min read
- Vue
- Nuxt
- Accessibility
- Streaming
- AI chat

Key takeaways
- EventSource is built for one-way notifications; a chat that POSTs its messages and needs headers is better off reading the fetch response body as a stream.
- Abort is user intent, not a failure: keep the partial answer and never paint the state red after a Stop press.
- Model output is untrusted input: a safe-by-default markdown parser plus DOMPurify, with nothing touching the string after sanitizing.
- Put the transcript in role=log and transient states in role=status; announce completions, not tokens.
- Keyboard support, 24 pixel target sizes and prefers-reduced-motion belong to the same definition of done as the stream parser.
A chat box is the easiest LLM feature to demo and one of the hardest to ship well. The answer arrives token by token, the user watches the text grow, and inside that loop sit three separate obligations: keep the stream honest when the network misbehaves, keep model output from executing in the browser, and keep the interface usable for someone who never sees the text appear. I build Vue and Nuxt applications for B2B clients in Austria, and in every chat review the streaming works and the accessibility does not.
What follows is a build order rather than a library review. We pick a transport, read it in a Vue composable with a reader and an AbortController, model the connection as four states, sanitize the markdown the model produced, and then do the part that usually gets skipped: announcing new content to screen readers, keeping the composer keyboard-operable and honoring prefers-reduced-motion. The basics of streaming out of a Nuxt server route are in my earlier article on shipping LLM features in Nuxt; this one goes deeper on the interface around the stream.
Choose the transport before you choose the component
Two transports dominate chat interfaces. The browser-native EventSource speaks server-sent events: the server answers with the text/event-stream content type and UTF-8 messages built from event, data, id and retry fields, and the browser takes care of reconnection for you. That resilience has two sharp edges in a chat. The connection is one-way, so it cannot carry the POST request that submits your message history, and reconnection is automatic: after a network error the browser retries without asking, which is exactly wrong for a one-shot token stream where a silent retry could duplicate a half-rendered answer.
There is also an infrastructure limit worth knowing before you commit. Over HTTP/1.1, browsers cap open connections at six per browser and domain, a limit marked as won't fix in both Chrome and Firefox, while with HTTP/2 the number of simultaneous streams is negotiated between browser and server and defaults to 100. If your platform still terminates connections over HTTP/1.1, a handful of open chat tabs already sits at the ceiling.
| Approach | Direction | Auth | Reconnect | What you still handle |
|---|---|---|---|---|
| EventSource (SSE) | server to client only | GET, no request body | automatic via the retry field | the entire send path |
| fetch plus ReadableStream | request body and response stream | full headers and body | none, you decide | parsing, abort, error states |
| AI SDK useChat | POST with a UI message stream | configurable headers and body | handled by the transport | state, rendering, accessibility |
For a chat that submits a message list, needs an Authorization header and must stop the moment the user presses Stop, the fetch response body read as a ReadableStream is the honest default, and it is what the AI SDK's useChat hook does under the hood, including for its Vue package. The rest of this article builds that loop by hand, so that when you do reach for the hook you know what it is doing for you and where its state model ends and your accessibility work begins.
Reading a fetch stream in a Vue composable
A fetch response exposes its body as a ReadableStream. You can lock it with getReader() and read in a loop, or skip the reader and iterate the chunks directly: the Streams API implements the async iterable protocol, so for await...of over the response body works out of the box. MDN notes two details that matter in production: iterating locks the stream so nothing else can consume it, and leaving the loop early cancels the stream unless you pass preventCancel. For the bytes, pipe the body through a TextDecoderStream, the streaming form of TextDecoder that converts a binary encoding into a stream of strings.
<script setup lang="ts">
import { ref, onBeforeUnmount } from "vue"
type Status = "ready" | "streaming" | "error"
const reply = ref("")
const status = ref<Status>("ready")
let controller: AbortController | null = null
async function send(text: string) {
controller?.abort()
controller = new AbortController()
status.value = "streaming"
reply.value = ""
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader()
let leftover = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
leftover += value
const events = leftover.split("\n\n")
leftover = events.pop() ?? ""
for (const ev of events) {
if (ev.startsWith("data: ")) reply.value += ev.slice(6)
}
}
status.value = "ready"
} catch (err) {
// a Stop press is not a failure
status.value = (err as Error).name === "AbortError" ? "ready" : "error"
}
}
onBeforeUnmount(() => controller?.abort())
</script>Two lines in that loop deserve a comment. The leftover buffer exists because network chunks are not aligned with your protocol: a single data event can be split across two reads, so you process only complete, blank-line-separated events and keep the rest for the next iteration. And the catch block treats an AbortError as a normal ending, because a Stop press is user intent, not an error. Everything else, including a non-2xx status detected before the stream starts, moves the state to error where the interface can offer a retry.
Abort is a feature, not an error
AbortController is the standard tool for this: its abort() method aborts fetch requests, the consumption of response bodies and streams. Wire one controller per conversation turn, abort the previous request before starting the next, and abort on unmount, so that navigating away during a long generation does not leave the server streaming into a dead tab. The subtle part is the interface around it: the partial answer that already arrived is valid text, so keep it in the transcript, mark the message as stopped and never paint the state red.
// stop is user intent: abort and keep whatever arrived
function stop() {
controller?.abort()
status.value = "ready"
}
// retry resends the last user message, not the last wire payload
async function retry() {
const last = messages.value.at(-1)
if (last?.role === "user") await send(last.text)
}Retry belongs in the flow as a normal button that takes focus when it appears, not as a hidden gesture. The AI SDK exposes exactly this pair: stop aborts the fetch request, regenerate asks the model to reprocess the last message, and status gates which of them is enabled. When something does fail, show a short generic message: the AI SDK documentation recommends a plain something-went-wrong text instead of surfacing the raw error, which keeps server details out of a public interface, while the real error goes to your logs where you can act on it.
Model the connection as four states
Derive every visible control from a single status value. The AI SDK's useChat hook returns a status with four values: submitted means the request is on the wire and the stream has not started, streaming means chunks are arriving, ready means the response finished and a new message can be sent, and error means the request failed. A send button disabled while not ready, a Stop button only while submitted or streaming, and retry only in the error state is a state machine you can test without a screen reader, and it is also the machine that assistive technology will report through your status region.
Throttling belongs to the state model, not to a performance backlog. useChat documents a throttle option for Vue that batches reactive message updates (the documentation uses 50 milliseconds) while stream processing and event callbacks stay immediate, and it guarantees that the latest messages are still published before the chat reaches a terminal state. Without it, a fast model mutates your reactive array dozens of times per second and every consumer re-renders along with it.
Vue helps you on the render side: when you mutate reactive state, DOM updates are not applied synchronously but buffered until the next tick, so several chunk writes in one tick collapse into a single patch. Do not measure the transcript DOM in the same tick you appended a chunk; await nextTick() first. This is also where the accessibility and performance budgets meet, because fewer renders per keystroke keep the interaction next paint of the whole page honest, which is exactly what the Core Web Vitals guidance for Nuxt sites is about.
Render markdown without handing over the DOM
Model output is user-influenced input. A prompt can ask the model to emit a script tag, an iframe or a javascript link, and anyone who can steer the conversation can try it for real, so this is the same threat model as any other untrusted HTML. The safe combination is a markdown parser that is safe by default plus a sanitizer on its output. markdown-it advertises exactly that: it follows CommonMark and it is safe by default, which means raw HTML in the markdown is escaped rather than passed through unless you opt in. DOMPurify then mops up whatever the parser produced.
import MarkdownIt from "markdown-it"
import DOMPurify from "dompurify"
// html stays false: raw HTML in model output is escaped
const md = new MarkdownIt({ linkify: true })
export function renderAssistantMarkdown(src: string): string {
const html = md.render(src)
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true }, // replies need no SVG or MathML
ADD_ATTR: ["target"],
})
}Keep the allow-list narrow and the pipeline one-directional: markdown in, sanitized HTML out, and only then rendered with v-html. Links get their target and rel attributes from your renderer, never from the model, and anything that looks like a tool call or a product card is drawn by a Vue component you control. A screen reader also meets this content, so keep the generated markup semantic: real headings, real lists, real code elements, because assistive technology navigates by exactly those structures.
Announce the answer, not every token
Here is the part most implementations skip. WCAG 2.2 became a W3C Recommendation on 5 October 2023, and its success criterion 4.1.3 Status Messages, level AA, requires that status messages be programmatically determinable through role or properties so assistive technologies can present them without the element receiving focus. The WAI techniques point to role=status for results and waiting states, role=log for sequential updates and role=alert for errors. A chat transcript is the textbook case for role=log: MDN describes it as a live region where new information is added at the end in meaningful order, which is precisely how a conversation grows.
Three details decide how that feels in practice. A role=log region has an implicit aria-live value of polite, so announcements wait until the user is idle, and an implicit aria-atomic of false, so only the delta is spoken. The region must have an accessible name, so give it aria-label or aria-labelledby. And the transcript must contain the finished message, because a screen reader user who finds the log later expects the answer to be there as readable text.
Keep the announcement surface small: one status line for transient states such as thinking, stopped and error, and the log region for content. Because polite live regions announce only what changed, publishing the assistant message once, after the stream ends, produces a single clean announcement, while publishing every token range would produce a stutter. The WAI understanding document for 4.1.3 warns from the other direction too: an application can become too chatty, so test the announcement rhythm with real screen reader users instead of guessing.
<section aria-labelledby="chat-heading">
<h2 id="chat-heading">Support chat</h2>
<!-- transcript: finished messages announced politely, as a delta -->
<ol role="log" aria-live="polite" aria-label="Chat messages">
<li v-for="m in messages" :key="m.id">
<span class="who">{{ m.role === 'user' ? 'You' : 'Assistant' }}</span>
<div v-html="m.html"></div>
</li>
</ol>
<!-- transient state: announced once, never focused -->
<p role="status">{{ statusText }}</p>
<form @submit.prevent="send">
<label for="chat-input">Message</label>
<textarea id="chat-input" v-model="draft"
@keydown.enter.exact.prevent="send"></textarea>
<button type="button" v-if="status === 'streaming'" @click="stop">Stop</button>
<button type="submit" :disabled="status === 'streaming'">Send</button>
</form>
</section>Keyboard and focus: the composer is the app
Everything in a chat must work from the keyboard alone, and the composer is where that usually breaks. Keep focus in the textarea after sending, so a user who fires question after question never has to Tab back. Enter sends and Shift+Enter inserts a newline, but only intercept the key on the textarea itself, never on a wrapping div. Send and Stop are real buttons, so Enter and Space work for free, and the Stop button that appears mid-stream must not steal focus from someone still typing. Nothing should be reachable only by hovering over it.
Two criteria in WCAG 2.2 are specific to this layout. Target Size (Minimum), 2.5.8 at level AA, asks for pointer targets of at least 24 by 24 CSS pixels or sufficient spacing around smaller ones, which kills the temptation to draw Stop as a tiny inline glyph. Focus Not Obscured (Minimum), 2.4.11 at level AA, requires that a component receiving keyboard focus is not entirely hidden by author-created content, which is exactly the risk of a sticky composer bar above a scrolling transcript: add scroll padding and scroll margin so that links inside messages stay visible when they receive focus.
Respect prefers-reduced-motion
prefers-reduced-motion detects the setting people enable to minimize non-essential motion, and MDN names scaling and panning of large objects as classic triggers of discomfort for users with vestibular motion disorders. A chat is full of exactly those effects: pulsing typing dots, a blinking caret, a smooth auto-scroll that glides the transcript up with every chunk. The fix is cheap and it is CSS: keep the animation for people who want it, and replace it with an instant state change for everyone else.
.typing-dot { animation: pulse 1.1s ease-in-out infinite; }
.chat-log { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
.typing-dot { animation: none; }
.chat-log { scroll-behavior: auto; }
}Two opinions worth defending in a review. First, auto-scroll should jump rather than glide when motion is reduced, and it should stop entirely the moment the user scrolls up to read something, because fighting the reader is worse than any animation. Second, never gate the only signal of activity behind motion: if the typing indicator is the sole proof that the model is working, a user with reduced motion has no proof at all. If you need the preference on the server, there is a user-agent client hint for it, Sec-CH-Prefers-Reduced-Motion.
Testing the chat UI like a user, not like a linter
Test the way the failure reaches real users. Do one keyboard-only pass: Tab to the composer, Enter to send, confirm that Stop appears and is reachable while the answer streams, and that retry takes focus when it shows up. Do one screen reader pass with VoiceOver on macOS and NVDA on Windows, the two that matter for my Austrian and Hungarian clients: the reply should be announced once, the error should be announced at all, and nothing should repeat itself while tokens stream. Run an automated axe scan in CI to catch missing names and roles, but treat it as a floor, not as evidence.
Then add the three regression tests you would want at two in the morning. First, kill the connection mid-stream and assert that the UI lands in the error state with a retry action and no unhandled rejection. Second, press Stop and assert that the state is not error and that the partial answer survives. Third, send a message that makes the model emit a script tag and assert that it renders as text. These three tests cover most of what this article describes and they run in any headless browser. If you sell into the EU, the European Accessibility Act already treats accessible interfaces as a commercial requirement, so this work is not optional either.
Sources
Frequently asked questions
Should the streaming text itself live in an aria-live region?
No. Publish the finished message into the log region and use a small status line for thinking, stopped and error. A live region that receives token updates turns a screen reader into a stutter machine; publish at completion or at sentence boundaries and let the transcript hold the full text for the reader to explore.
Why not just use EventSource for the response?
Because it is one-way and its reconnection is automatic. EventSource only receives, so the POST that submits your messages still needs fetch, and the built-in retry can silently replay a stream you have already half-rendered. A fetch response body gives you request and response in one call and leaves abort under your control.
Is markdown-it alone not enough?
It escapes raw HTML by default, but a sanitizer is cheap insurance. Linkified URLs, plugin output and future options can widen the surface, and DOMPurify defends against parser-mutation, namespace and clobbering tricks that hand-written filters miss. Run it last, and never modify the string after sanitizing.
How do I keep the UI responsive while tokens stream in?
Batch the reactive updates. The AI SDK exposes a throttle option for Vue, and Vue already buffers DOM patches until the next tick, so appending a chunk stays cheap as long as you avoid heavy child components and scroll math in the same tick. Fewer renders per second also protect the interaction next paint of the page.
Do I really have to test with a screen reader?
Yes, and more cheaply than you fear. Five minutes with VoiceOver on macOS or NVDA on Windows surfaces the two classic bugs here: a transcript that is never announced and an error state that is visually obvious but silent. Automated checks catch missing roles; they do not tell you whether the announcements are usable.