[{"data":1,"prerenderedAt":773},["ShallowReactive",2],{"blog-vue-ai-chat-ui-streaming-accessibility-en":3},{"slug":4,"published":5,"minutes":6,"category":7,"tags":8,"keywords":14,"about":26,"sources":36,"cover":73,"og":74,"expertise":75,"locales":76,"lang":77,"title":80,"description":81,"coverAlt":82,"metaTitle":83,"takeaways":84,"faq":90,"toc":106,"blocks":137,"others":527},"vue-ai-chat-ui-streaming-accessibility","2026-10-17",11,"web",[9,10,11,12,13],"Vue","Nuxt","Accessibility","Streaming","AI chat",[15,16,17,18,19,20,21,22,23,24,25],"vue ai chat ui","nuxt llm streaming","sse vs fetch streaming","aria-live streaming text","accessible chat interface","abort fetch stream vue","dompurify markdown llm","role log chat","reduced motion chat ui","wcag 2.2 chat","screen reader streaming",[27,30,33],{"name":28,"url":29},"Web accessibility","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FWeb_accessibility",{"name":31,"url":32},"Server-sent events","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FServer-sent_events",{"name":34,"url":35},"Vue.js","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FVue.js",[37,40,43,46,49,52,55,58,61,64,67,70],{"title":38,"url":39},"MDN: Using server-sent events","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FServer-sent_events\u002FUsing_server-sent_events",{"title":41,"url":42},"MDN: AbortController","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAbortController",{"title":44,"url":45},"MDN: ReadableStream","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FReadableStream",{"title":47,"url":48},"MDN: TextDecoderStream","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FTextDecoderStream",{"title":50,"url":51},"MDN: ARIA log role","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FReference\u002FRoles\u002Flog_role",{"title":53,"url":54},"W3C: Understanding SC 4.1.3 Status Messages","https:\u002F\u002Fwww.w3.org\u002FWAI\u002FWCAG22\u002FUnderstanding\u002Fstatus-messages.html",{"title":56,"url":57},"W3C WAI: What's new in WCAG 2.2","https:\u002F\u002Fwww.w3.org\u002FWAI\u002Fstandards-guidelines\u002Fwcag\u002Fnew-in-22\u002F",{"title":59,"url":60},"MDN: prefers-reduced-motion","https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FCSS\u002F@media\u002Fprefers-reduced-motion",{"title":62,"url":63},"DOMPurify README","https:\u002F\u002Fgithub.com\u002Fcure53\u002FDOMPurify",{"title":65,"url":66},"markdown-it README","https:\u002F\u002Fgithub.com\u002Fmarkdown-it\u002Fmarkdown-it",{"title":68,"url":69},"AI SDK docs: useChat","https:\u002F\u002Fai-sdk.dev\u002Fdocs\u002Fai-sdk-ui\u002Fchatbot",{"title":71,"url":72},"Vue docs: nextTick","https:\u002F\u002Fvuejs.org\u002Fapi\u002Fgeneral.html#nexttick","\u002Fimages\u002Fblog\u002Fvue-ai-chat-ui-streaming-accessibility\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fvue-ai-chat-ui-streaming-accessibility\u002Fog.jpg","vue-nuxt-developer",[77,78,79],"en","de","hu","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.","A message card with a careful caret, a dotted line opening into ripples and a check.","Streaming AI chat in Vue, accessible by design · Balázs Csorba",[85,86,87,88,89],"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.",[91,94,97,100,103],{"q":92,"a":93},"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.",{"q":95,"a":96},"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.",{"q":98,"a":99},"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.",{"q":101,"a":102},"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.",{"q":104,"a":105},"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.",[107,110,113,116,119,122,125,128,131,134],{"id":108,"title":109},"streaming-choices","Choose the transport before you choose the component",{"id":111,"title":112},"reading-the-stream","Reading a fetch stream in a Vue composable",{"id":114,"title":115},"abort-and-retry","Abort is a feature, not an error",{"id":117,"title":118},"stream-state-model","Model the connection as four states",{"id":120,"title":121},"rendering-markdown-safely","Render markdown without handing over the DOM",{"id":123,"title":124},"announcing-streams","Announce the answer, not every token",{"id":126,"title":127},"keyboard-and-focus","Keyboard and focus: the composer is the app",{"id":129,"title":130},"reduced-motion","Respect prefers-reduced-motion",{"id":132,"title":133},"testing-the-chat-ui","Testing the chat UI like a user, not like a linter",{"id":135,"title":136},"sources","Sources",[138,142,160,163,189,192,239,254,255,277,279,289,290,299,301,316,325,326,348,354,366,367,370,372,388,395,396,411,430,437,440,442,451,452,455,458,459,464,466,473,474,477,485,486],{"type":139,"content":140},"paragraph",[141],"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.",{"type":139,"content":143},[144,145,149,150,153,154,159],"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 ",{"tag":146,"children":147},"code",[148],"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 ",{"tag":146,"children":151},[152],"prefers-reduced-motion",". The basics of streaming out of a Nuxt server route are in my earlier article on ",{"tag":155,"to":156,"children":157},"link","\u002Fblog\u002Fnuxt-llm-features-ai-sdk-streaming",[158],"shipping LLM features in Nuxt","; this one goes deeper on the interface around the stream.",{"type":161,"level":162,"id":108,"text":109},"heading",2,{"type":139,"content":164},[165,166,169,170,173,174,177,178,177,181,184,185,188],"Two transports dominate chat interfaces. The browser-native ",{"tag":146,"children":167},[168],"EventSource"," speaks server-sent events: the server answers with the ",{"tag":146,"children":171},[172],"text\u002Fevent-stream"," content type and UTF-8 messages built from ",{"tag":146,"children":175},[176],"event",", ",{"tag":146,"children":179},[180],"data",{"tag":146,"children":182},[183],"id"," and ",{"tag":146,"children":186},[187],"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.",{"type":139,"content":190},[191],"There is also an infrastructure limit worth knowing before you commit. Over HTTP\u002F1.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\u002F2 the number of simultaneous streams is negotiated between browser and server and defaults to 100. If your platform still terminates connections over HTTP\u002F1.1, a handful of open chat tabs already sits at the ceiling.",{"type":193,"head":194,"rows":205},"table",[195,197,199,201,203],[196],"Approach",[198],"Direction",[200],"Auth",[202],"Reconnect",[204],"What you still handle",[206,217,228],[207,209,211,213,215],[208],"EventSource (SSE)",[210],"server to client only",[212],"GET, no request body",[214],"automatic via the retry field",[216],"the entire send path",[218,220,222,224,226],[219],"fetch plus ReadableStream",[221],"request body and response stream",[223],"full headers and body",[225],"none, you decide",[227],"parsing, abort, error states",[229,231,233,235,237],[230],"AI SDK useChat",[232],"POST with a UI message stream",[234],"configurable headers and body",[236],"handled by the transport",[238],"state, rendering, accessibility",{"type":139,"content":240},[241,242,245,246,249,250,253],"For a chat that submits a message list, needs an ",{"tag":146,"children":243},[244],"Authorization"," header and must stop the moment the user presses Stop, the fetch response body read as a ",{"tag":146,"children":247},[248],"ReadableStream"," is the honest default, and it is what the AI SDK's ",{"tag":146,"children":251},[252],"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.",{"type":161,"level":162,"id":111,"text":112},{"type":139,"content":256},[257,258,260,261,264,265,268,269,272,273,276],"A fetch response exposes its body as a ",{"tag":146,"children":259},[248],". You can lock it with ",{"tag":146,"children":262},[263],"getReader()"," and read in a loop, or skip the reader and iterate the chunks directly: the Streams API implements the async iterable protocol, so ",{"tag":146,"children":266},[267],"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 ",{"tag":146,"children":270},[271],"preventCancel",". For the bytes, pipe the body through a ",{"tag":146,"children":274},[275],"TextDecoderStream",", the streaming form of TextDecoder that converts a binary encoding into a stream of strings.",{"type":146,"code":278},"\u003Cscript setup lang=\"ts\">\nimport { ref, onBeforeUnmount } from \"vue\"\n\ntype Status = \"ready\" | \"streaming\" | \"error\"\nconst reply = ref(\"\")\nconst status = ref\u003CStatus>(\"ready\")\nlet controller: AbortController | null = null\n\nasync function send(text: string) {\n  controller?.abort()\n  controller = new AbortController()\n  status.value = \"streaming\"\n  reply.value = \"\"\n  try {\n    const res = await fetch(\"\u002Fapi\u002Fchat\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application\u002Fjson\" },\n      body: JSON.stringify({ message: text }),\n      signal: controller.signal,\n    })\n    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)\n    const reader = res.body\n      .pipeThrough(new TextDecoderStream())\n      .getReader()\n    let leftover = \"\"\n    for (;;) {\n      const { done, value } = await reader.read()\n      if (done) break\n      leftover += value\n      const events = leftover.split(\"\\n\\n\")\n      leftover = events.pop() ?? \"\"\n      for (const ev of events) {\n        if (ev.startsWith(\"data: \")) reply.value += ev.slice(6)\n      }\n    }\n    status.value = \"ready\"\n  } catch (err) {\n    \u002F\u002F a Stop press is not a failure\n    status.value = (err as Error).name === \"AbortError\" ? \"ready\" : \"error\"\n  }\n}\n\nonBeforeUnmount(() => controller?.abort())\n\u003C\u002Fscript>",{"type":139,"content":280},[281,282,284,285,288],"Two lines in that loop deserve a comment. The leftover buffer exists because network chunks are not aligned with your protocol: a single ",{"tag":146,"children":283},[180]," 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 ",{"tag":146,"children":286},[287],"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.",{"type":161,"level":162,"id":114,"text":115},{"type":139,"content":291},[292,294,295,298],{"tag":146,"children":293},[148]," is the standard tool for this: its ",{"tag":146,"children":296},[297],"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.",{"type":146,"code":300},"\u002F\u002F stop is user intent: abort and keep whatever arrived\nfunction stop() {\n  controller?.abort()\n  status.value = \"ready\"\n}\n\n\u002F\u002F retry resends the last user message, not the last wire payload\nasync function retry() {\n  const last = messages.value.at(-1)\n  if (last?.role === \"user\") await send(last.text)\n}",{"type":139,"content":302},[303,304,307,308,311,312,315],"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: ",{"tag":146,"children":305},[306],"stop"," aborts the fetch request, ",{"tag":146,"children":309},[310],"regenerate"," asks the model to reprocess the last message, and ",{"tag":146,"children":313},[314],"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.",{"type":317,"attrs":318,"inner":322,"caption":323},"diagram",{"viewBox":319,"role":320,"aria-labelledby":321},"0 0 720 200","img","vaic1-t vaic1-d","\u003Ctitle id=\"vaic1-t\">Chat stream lifecycle\u003C\u002Ftitle>\u003Cdesc id=\"vaic1-d\">The composer sends a POST request, the server streams token chunks back, the client appends and sanitizes them, and the stream ends in a done state; the Stop path aborts the fetch and the error path offers a retry.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"vaic1-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"79\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Composer\u003C\u002Ftext>\u003Ctext x=\"79\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">send\u003C\u002Ftext>\u003Cpath d=\"M138 68H158\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"160\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"219\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">POST\u003C\u002Ftext>\u003Ctext x=\"219\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">\u002Fapi\u002Fchat\u003C\u002Ftext>\u003Cpath d=\"M278 68H298\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"300\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"359\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Stream\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">token chunks\u003C\u002Ftext>\u003Cpath d=\"M418 68H438\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"440\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"499\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Render\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">sanitize\u003C\u002Ftext>\u003Cpath d=\"M558 68H578\" class=\"d-line\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"580\" y=\"30\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"639\" y=\"64\" text-anchor=\"middle\" class=\"d-text\">Done\u003C\u002Ftext>\u003Ctext x=\"639\" y=\"86\" text-anchor=\"middle\" class=\"d-small\">ready state\u003C\u002Ftext>\u003Cpath d=\"M359 106V140\" class=\"d-dash\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"300\" y=\"142\" width=\"118\" height=\"52\" rx=\"10\" class=\"d-fill-muted\" \u002F>\u003Ctext x=\"359\" y=\"166\" text-anchor=\"middle\" class=\"d-text\">Stop\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"184\" text-anchor=\"middle\" class=\"d-small\">abort()\u003C\u002Ftext>\u003Cpath d=\"M499 106V140\" class=\"d-dash\" marker-end=\"url(#vaic1-ah)\" \u002F>\u003Crect x=\"440\" y=\"142\" width=\"118\" height=\"52\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"499\" y=\"166\" text-anchor=\"middle\" class=\"d-text\">Error\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"184\" text-anchor=\"middle\" class=\"d-small\">retry\u003C\u002Ftext>\u003C\u002Fsvg>",[324],"Two exits matter: Stop must be indistinguishable from success in the state machine, while a network failure must land on a retry action, not on a stack trace.",{"type":161,"level":162,"id":117,"text":118},{"type":139,"content":327},[328,329,331,332,335,336,339,340,343,344,347],"Derive every visible control from a single status value. The AI SDK's ",{"tag":146,"children":330},[252]," hook returns a status with four values: ",{"tag":146,"children":333},[334],"submitted"," means the request is on the wire and the stream has not started, ",{"tag":146,"children":337},[338],"streaming"," means chunks are arriving, ",{"tag":146,"children":341},[342],"ready"," means the response finished and a new message can be sent, and ",{"tag":146,"children":345},[346],"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.",{"type":139,"content":349},[350,351,353],"Throttling belongs to the state model, not to a performance backlog. ",{"tag":146,"children":352},[252]," 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.",{"type":139,"content":355},[356,357,360,361,365],"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 ",{"tag":146,"children":358},[359],"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 ",{"tag":155,"to":362,"children":363},"\u002Fblog\u002Fnuxt-core-web-vitals-performance",[364],"Core Web Vitals guidance for Nuxt sites"," is about.",{"type":161,"level":162,"id":120,"text":121},{"type":139,"content":368},[369],"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.",{"type":146,"code":371},"import MarkdownIt from \"markdown-it\"\nimport DOMPurify from \"dompurify\"\n\n\u002F\u002F html stays false: raw HTML in model output is escaped\nconst md = new MarkdownIt({ linkify: true })\n\nexport function renderAssistantMarkdown(src: string): string {\n  const html = md.render(src)\n  return DOMPurify.sanitize(html, {\n    USE_PROFILES: { html: true }, \u002F\u002F replies need no SVG or MathML\n    ADD_ATTR: [\"target\"],\n  })\n}",{"type":373,"variant":374,"title":375,"body":376},"callout","warn","Sanitize last, and mean it",[377],[378,379,382,383,387],"DOMPurify's own documentation warns that if you sanitize first and modify the markup afterwards, you can easily void the effects of sanitization. So no syntax highlighter, link rewriter or copy-button helper may touch the string after ",{"tag":146,"children":380},[381],"sanitize()","; do that work as Vue components or data attributes on top of the sanitized output. Two more footguns from the same README: the default configuration also permits SVG and MathML, which a chat reply never needs, and if you sanitize on the server, do not use happy-dom, which the project states is not considered safe. The wider moderation story lives in my piece on ",{"tag":155,"to":384,"children":385},"\u002Fblog\u002Fllm-output-guardrails-moderation",[386],"LLM output guardrails","; rendering is the last line of that defense, not the first.",{"type":139,"content":389},[390,391,394],"Keep the allow-list narrow and the pipeline one-directional: markdown in, sanitized HTML out, and only then rendered with ",{"tag":146,"children":392},[393],"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.",{"type":161,"level":162,"id":123,"text":124},{"type":139,"content":397},[398,399,402,403,406,407,410],"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 ",{"tag":146,"children":400},[401],"role=status"," for results and waiting states, ",{"tag":146,"children":404},[405],"role=log"," for sequential updates and ",{"tag":146,"children":408},[409],"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.",{"type":139,"content":412},[413,414,417,418,421,422,425,426,429],"Three details decide how that feels in practice. A role=log region has an implicit ",{"tag":146,"children":415},[416],"aria-live"," value of polite, so announcements wait until the user is idle, and an implicit ",{"tag":146,"children":419},[420],"aria-atomic"," of false, so only the delta is spoken. The region must have an accessible name, so give it ",{"tag":146,"children":423},[424],"aria-label"," or ",{"tag":146,"children":427},[428],"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.",{"type":317,"attrs":431,"inner":434,"caption":435},{"viewBox":432,"role":320,"aria-labelledby":433},"0 0 720 170","vaic2-t vaic2-d","\u003Ctitle id=\"vaic2-t\">Announcement pipeline\u003C\u002Ftitle>\u003Cdesc id=\"vaic2-d\">Raw tokens are buffered into sentence-sized pieces, published once into a polite log region, and spoken by the screen reader when the user is idle.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"vaic2-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"79\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Tokens\u003C\u002Ftext>\u003Ctext x=\"79\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">raw stream\u003C\u002Ftext>\u003Cpath d=\"M138 85H158\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"160\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"219\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Buffer\u003C\u002Ftext>\u003Ctext x=\"219\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">sentence sized\u003C\u002Ftext>\u003Cpath d=\"M278 85H298\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"300\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"359\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Log region\u003C\u002Ftext>\u003Ctext x=\"359\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">polite\u003C\u002Ftext>\u003Cpath d=\"M418 85H438\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"440\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"499\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Delta\u003C\u002Ftext>\u003Ctext x=\"499\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">announced once\u003C\u002Ftext>\u003Cpath d=\"M558 85H578\" class=\"d-line\" marker-end=\"url(#vaic2-ah)\" \u002F>\u003Crect x=\"580\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"639\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Reader\u003C\u002Ftext>\u003Ctext x=\"639\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">idle time speech\u003C\u002Ftext>\u003C\u002Fsvg>",[436],"The buffer is the accessibility feature: the reader hears one finished answer, not fifty token updates.",{"type":139,"content":438},[439],"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.",{"type":146,"code":441},"\u003Csection aria-labelledby=\"chat-heading\">\n  \u003Ch2 id=\"chat-heading\">Support chat\u003C\u002Fh2>\n\n  \u003C!-- transcript: finished messages announced politely, as a delta -->\n  \u003Col role=\"log\" aria-live=\"polite\" aria-label=\"Chat messages\">\n    \u003Cli v-for=\"m in messages\" :key=\"m.id\">\n      \u003Cspan class=\"who\">{{ m.role === 'user' ? 'You' : 'Assistant' }}\u003C\u002Fspan>\n      \u003Cdiv v-html=\"m.html\">\u003C\u002Fdiv>\n    \u003C\u002Fli>\n  \u003C\u002Fol>\n\n  \u003C!-- transient state: announced once, never focused -->\n  \u003Cp role=\"status\">{{ statusText }}\u003C\u002Fp>\n\n  \u003Cform @submit.prevent=\"send\">\n    \u003Clabel for=\"chat-input\">Message\u003C\u002Flabel>\n    \u003Ctextarea id=\"chat-input\" v-model=\"draft\"\n      @keydown.enter.exact.prevent=\"send\">\u003C\u002Ftextarea>\n    \u003Cbutton type=\"button\" v-if=\"status === 'streaming'\" @click=\"stop\">Stop\u003C\u002Fbutton>\n    \u003Cbutton type=\"submit\" :disabled=\"status === 'streaming'\">Send\u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Fsection>",{"type":373,"variant":443,"title":444,"body":445},"note","Announce completions, not tokens",[446],[447,448,450],"A streaming answer changes every few tens of milliseconds. If each change reaches a live region, a screen reader user hears a constant stutter of partial sentences and never forms a picture of the answer. Buffer the chunks and publish the message to the log region when the stream ends or at sentence boundaries; MDN also notes that setting ",{"tag":146,"children":449},[420]," to true makes assistive technology read the entire region again, which is useful for short status text and noisy for a long reply. The wrong version of this feature is worse than none at all, so ship the buffered one.",{"type":161,"level":162,"id":126,"text":127},{"type":139,"content":453},[454],"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.",{"type":139,"content":456},[457],"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.",{"type":161,"level":162,"id":129,"text":130},{"type":139,"content":460},[461,463],{"tag":146,"children":462},[152]," 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.",{"type":146,"code":465},".typing-dot { animation: pulse 1.1s ease-in-out infinite; }\n.chat-log { scroll-behavior: smooth; }\n\n@media (prefers-reduced-motion: reduce) {\n  .typing-dot { animation: none; }\n  .chat-log { scroll-behavior: auto; }\n}",{"type":139,"content":467},[468,469,472],"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, ",{"tag":146,"children":470},[471],"Sec-CH-Prefers-Reduced-Motion",".",{"type":161,"level":162,"id":132,"text":133},{"type":139,"content":475},[476],"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.",{"type":139,"content":478},[479,480,484],"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 ",{"tag":155,"to":481,"children":482},"\u002Fblog\u002Feuropean-accessibility-act-b2b-shop",[483],"European Accessibility Act"," already treats accessible interfaces as a commercial requirement, so this work is not optional either.",{"type":161,"level":162,"id":135,"text":136},{"type":487,"ordered":488,"items":489},"list",true,[490,494,497,500,503,506,509,512,515,518,521,524],[491],{"tag":492,"href":39,"children":493},"a",[38],[495],{"tag":492,"href":42,"children":496},[41],[498],{"tag":492,"href":45,"children":499},[44],[501],{"tag":492,"href":48,"children":502},[47],[504],{"tag":492,"href":51,"children":505},[50],[507],{"tag":492,"href":54,"children":508},[53],[510],{"tag":492,"href":57,"children":511},[56],[513],{"tag":492,"href":60,"children":514},[59],[516],{"tag":492,"href":63,"children":517},[62],[519],{"tag":492,"href":66,"children":520},[65],[522],{"tag":492,"href":69,"children":523},[68],[525],{"tag":492,"href":72,"children":526},[71],[528,601,657,725],{"slug":529,"published":5,"minutes":530,"category":7,"tags":531,"keywords":536,"about":548,"sources":557,"cover":594,"og":595,"expertise":596,"locales":597,"lang":77,"title":598,"description":599,"coverAlt":600},"schema-org-structured-data-b2b-shop",10,[532,533,534,535,10],"Schema.org","Structured data","B2B e-commerce","Technical SEO",[537,538,539,540,541,542,543,544,545,546,547],"schema.org b2b shop","product structured data b2b","merchant listing structured data","product snippet vs merchant listing","unitpricespecification referencequantity","productgroup variants structured data","organization schema vatid","b2b prices behind login seo","json-ld nuxt schema org","merchant return policy structured data","mpn gtin structured data",[549,551,554],{"name":532,"url":550},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FSchema.org",{"name":552,"url":553},"JSON-LD","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FJSON-LD",{"name":555,"url":556},"Business-to-business","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FBusiness-to-business",[558,561,564,567,570,573,576,579,582,585,588,591],{"title":559,"url":560},"Google Search Central: Introduction to Product structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct",{"title":562,"url":563},"Google Search Central: Merchant listing (Product, Offer) structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fmerchant-listing",{"title":565,"url":566},"Google Search Central: Product snippet structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct-snippet",{"title":568,"url":569},"Google Search Central: Product variant structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fproduct-variants",{"title":571,"url":572},"Google Search Central: Organization structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Forganization",{"title":574,"url":575},"Google Search Central: Merchant return policy structured data","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Freturn-policy",{"title":577,"url":578},"Google Search Central: General structured data guidelines","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fsd-policies",{"title":580,"url":581},"Google Search Central: Generate structured data with JavaScript","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fappearance\u002Fstructured-data\u002Fgenerate-structured-data-with-javascript",{"title":583,"url":584},"Schema.org: Offer","https:\u002F\u002Fschema.org\u002FOffer",{"title":586,"url":587},"Schema.org: UnitPriceSpecification","https:\u002F\u002Fschema.org\u002FUnitPriceSpecification",{"title":589,"url":590},"Unhead: Product schema (defineProduct)","https:\u002F\u002Funhead.unjs.io\u002Fdocs\u002Fschema-org\u002Fapi\u002Fschema\u002Fproduct",{"title":592,"url":593},"Nuxt SEO: Schema.org identity setup","https:\u002F\u002Fnuxtseo.com\u002Fdocs\u002Fschema-org\u002Fguides\u002Fsetup-identity","\u002Fimages\u002Fblog\u002Fschema-org-structured-data-b2b-shop\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fschema-org-structured-data-b2b-shop\u002Fog.jpg","b2b-ecommerce-developer",[77,78,79],"Schema.org structured data for B2B shops: prices behind logins, units and variants","Product markup for B2B shops: snippet or merchant listing, which price to publish, part numbers, unit pricing, variants, Organization and SSR in Nuxt.","A product rectangle with a framed outline and a small price slot hidden behind a low wall.",{"slug":602,"published":5,"minutes":603,"category":7,"tags":604,"keywords":609,"about":621,"sources":629,"cover":651,"og":652,"expertise":596,"locales":653,"lang":77,"title":654,"description":655,"coverAlt":656},"ai-assistant-b2b-shop-customer-support",9,[534,605,606,607,608],"AI assistant","Customer support","LLM agents","EU AI Act",[610,611,612,613,614,615,616,617,618,619,620],"ai assistant b2b shop","ai customer support b2b ecommerce","llm chatbot order status","ai agent customer service tools","chatbot liability air canada","klarna ai assistant results","human handoff chatbot design","eu ai act chatbot disclosure","owasp excessive agency","ai support resolution rate","b2b customer portal chatbot",[622,625,628],{"name":623,"url":624},"Customer service","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FCustomer_service",{"name":626,"url":627},"Chatbot","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FChatbot",{"name":555,"url":556},[630,633,636,639,642,645,648],{"title":631,"url":632},"Klarna: AI assistant handles two-thirds of customer service chats in its first month (press release, 2024)","https:\u002F\u002Fwww.klarna.com\u002Finternational\u002Fpress\u002Fklarna-ai-assistant-handles-two-thirds-of-customer-service-chats-in-its-first-month\u002F",{"title":634,"url":635},"Entrepreneur: Klarna is hiring customer service agents again, according to its CEO (May 2025)","https:\u002F\u002Fwww.entrepreneur.com\u002Fbusiness-news\u002Fklarna-ceo-reverses-course-by-hiring-more-humans-not-ai\u002F491396",{"title":637,"url":638},"ABA Business Law Today: BC tribunal confirms companies remain liable for information provided by AI chatbot","https:\u002F\u002Fwww.americanbar.org\u002Fgroups\u002Fbusiness_law\u002Fresources\u002Fbusiness-law-today\u002F2024-february\u002Fbc-tribunal-confirms-companies-remain-liable-information-provided-ai-chatbot\u002F",{"title":640,"url":641},"OWASP GenAI Security Project: LLM06:2025 Excessive Agency","https:\u002F\u002Fgenai.owasp.org\u002Fllmrisk\u002Fllm062025-excessive-agency\u002F",{"title":643,"url":644},"OWASP GenAI Security Project: LLM05:2025 Improper Output Handling","https:\u002F\u002Fgenai.owasp.org\u002Fllmrisk\u002Fllm052025-improper-output-handling\u002F",{"title":646,"url":647},"EU AI Act, Article 50: Transparency obligations","https:\u002F\u002Fartificialintelligenceact.eu\u002Farticle\u002F50\u002F",{"title":649,"url":650},"Intercom: Fin pricing and billable outcomes","https:\u002F\u002Ffin.ai\u002Fpricing","\u002Fimages\u002Fblog\u002Fai-assistant-b2b-shop-customer-support\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fai-assistant-b2b-shop-customer-support\u002Fog.jpg",[77,78,79],"An AI assistant for B2B shop support: tools, permissions and the human handoff","How to build an AI support assistant for a B2B shop: which requests to automate, tools that run in the customer context, handoff, AI Act and metrics.","A tall wall with one narrow gap; an arrow passes through it into a boxed enclosure of three squares, and another leads down to a tray.",{"slug":658,"published":5,"minutes":603,"category":7,"tags":659,"keywords":664,"about":676,"sources":686,"cover":719,"og":720,"expertise":75,"locales":721,"lang":77,"title":722,"description":723,"coverAlt":724},"llm-translation-pipeline-i18n",[660,661,662,9,663],"Translation","i18n","LLM","Localization",[665,666,667,668,669,670,671,672,673,674,675],"llm translation pipeline","icu messageformat placeholders","vue i18n llm translation","translation memory llm","glossary enforcement translation","german informal sie du","hungarian translation quality llm","structured output json translation","machine translation post-editing","locale file validation","cldr plural categories",[677,680,683],{"name":678,"url":679},"Internationalization and localization","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FInternationalization_and_localization",{"name":681,"url":682},"ICU MessageFormat","https:\u002F\u002Funicode-org.github.io\u002Ficu\u002Fuserguide\u002Fformat_parse\u002Fmessages\u002F",{"name":684,"url":685},"Vue I18n","https:\u002F\u002Fvue-i18n.intlify.dev\u002F",[687,689,692,695,698,701,704,707,710,713,716],{"title":688,"url":682},"Unicode ICU User Guide: Formatting Messages",{"title":690,"url":691},"Vue I18n: Message Format Syntax","https:\u002F\u002Fvue-i18n.intlify.dev\u002Fguide\u002Fessentials\u002Fsyntax",{"title":693,"url":694},"Nuxt i18n: Getting started","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fgetting-started",{"title":696,"url":697},"Unicode CLDR: Plural rules","https:\u002F\u002Fcldr.unicode.org\u002Findex\u002Fcldr-spec\u002Fplural-rules",{"title":699,"url":700},"OpenAI: Structured outputs","https:\u002F\u002Fplatform.openai.com\u002Fdocs\u002Fguides\u002Fstructured-outputs",{"title":702,"url":703},"DeepL API: Glossaries","https:\u002F\u002Fdevelopers.deepl.com\u002Fdocs\u002Fapi-reference\u002Fglossaries",{"title":705,"url":706},"W3C: Localization vs. internationalization","https:\u002F\u002Fwww.w3.org\u002FInternational\u002Fquestions\u002Fqa-i18n",{"title":708,"url":709},"GNU gettext manual","https:\u002F\u002Fwww.gnu.org\u002Fsoftware\u002Fgettext\u002Fmanual\u002Fgettext.html",{"title":711,"url":712},"RFC 5646: Tags for Identifying Languages","https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc5646",{"title":714,"url":715},"Rat für deutsche Rechtschreibung","https:\u002F\u002Fwww.rechtschreibrat.com\u002F",{"title":717,"url":718},"Microsoft Writing Style Guide","https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fwelcome\u002F","\u002Fimages\u002Fblog\u002Fllm-translation-pipeline-i18n\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fllm-translation-pipeline-i18n\u002Fog.jpg",[77,78,79],"An LLM translation pipeline for i18n: glossary, placeholders and review","How to translate product and website strings with LLMs without breaking placeholders, plurals or tone: glossary, ICU messages, QA checks and review gates.","Three source cards, one translated card with a glossary ribbon, then a checked clipboard.",{"slug":726,"published":727,"minutes":728,"category":7,"tags":729,"keywords":733,"about":744,"sources":751,"cover":767,"og":768,"expertise":75,"locales":769,"lang":77,"title":770,"description":771,"coverAlt":772},"nuxt-i18n-multilingual-seo-hreflang","2026-10-16",8,[10,661,730,731,732],"SEO","hreflang","Multilingual",[734,735,736,737,738,739,740,741,742,743],"nuxt i18n seo","nuxt hreflang","useLocaleHead","nuxt i18n canonical","hreflang x-default","prefix_except_default","multilingual seo nuxt","nuxt i18n sitemap hreflang","hreflang self-referencing","nuxt i18n baseUrl",[745,747,750],{"name":10,"url":746},"https:\u002F\u002Fnuxt.com\u002F",{"name":748,"url":749},"Hreflang","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHreflang",{"name":678,"url":679},[752,755,758,761,764],{"title":753,"url":754},"Nuxt i18n docs: SEO (useLocaleHead)","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide\u002Fseo",{"title":756,"url":757},"Nuxt i18n docs: Routing strategies","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide",{"title":759,"url":760},"Google Search Central: Tell Google about localized versions of your page","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Flocalized-versions",{"title":762,"url":763},"Google Search Central: Managing multi-regional and multilingual sites","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Fmanaging-multi-regional-sites",{"title":765,"url":766},"Google Search Central: How to specify a canonical URL","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fcrawling-indexing\u002Fconsolidate-duplicate-urls","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fog.jpg",[77,78,79],"Multilingual SEO with Nuxt i18n: URL strategy, hreflang, canonicals and sitemaps","How to set up Nuxt i18n so search engines index every language: routing strategy, useLocaleHead, hreflang rules, partial translations and sitemaps.","A large sheet linked by three horizontal connectors to a column of three smaller sheets.",1792245345519]