Building an AI feature in Nuxt in 2026 takes three pieces that fit together without glue code. The Vercel AI SDK gives you the streaming engine and the native Composition API composables, useChat, useCompletion, and useObject, that expose a model’s output as reactive Vue state. Nuxt UI ships chat components wired to those composables, and Cloudflare Workers AI runs inference at the edge through the workers-ai-provider, which plugs into the same SDK surface as any hosted provider.
The only split is between packages. The ai package holds the server primitives, streamText, and the stream adapters that turn model output into a typed message stream, while @ai-sdk/vue holds the composables that consume it. You build in two provider phases: a local baseline through Vercel AI Gateway, which reaches many models behind one key, then an edge phase that swaps the provider for the workers-ai-provider bound to Cloudflare’s env.AI, running Cloudflare’s own models with no third-party model account. Your application code barely changes between the two, since both implement the same interface.
Scaffold a Nuxt app and install the AI packages. This uses Node 22 and pnpm.
pnpm create nuxt nuxt-ai-2026 cd nuxt-ai-2026 pnpm add ai @ai-sdk/vue zod
Your local baseline routes through Vercel AI Gateway, so you need a Gateway API key. Create one from the AI Gateway dashboard, then add it to nuxt.config.ts as runtime config.
export default defineNuxtConfig({
compatibilityDate: '2026-07-31',
devtools: { enabled: true },
runtimeConfig: {
aiGatewayApiKey: ''
}
})
Nuxt maps a NUXT_-prefixed environment variable onto matching runtime config, so NUXT_AI_GATEWAY_API_KEY fills aiGatewayApiKey at runtime. Put it in .env.
NUXT_AI_GATEWAY_API_KEY=your-key-here
Keep the model call in a Nitro route, so your provider key stays server-side and the browser never touches the model. The route uses four functions from ai: streamText runs the model, convertToModelMessages reduces the client’s rich message format to what the model expects, toUIMessageStream adapts the raw output into the typed UI stream, and createUIMessageStreamResponse wraps it in a response Nitro can send.
import {
streamText,
convertToModelMessages,
createGateway,
createUIMessageStreamResponse,
toUIMessageStream,
type UIMessage
} from 'ai'
export default defineLazyEventHandler(async () => {
const apiKey = useRuntimeConfig().aiGatewayApiKey
if (!apiKey) throw new Error('Missing AI Gateway API key')
const gateway = createGateway({ apiKey })
return defineEventHandler(async (event) => {
const { messages }: { messages: UIMessage[] } = await readBody(event)
const result = streamText({
model: gateway('inclusionai/ling-3.0-flash-free'),
messages: await convertToModelMessages(messages)
})
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream })
})
})
})
Wrapping in defineLazyEventHandler builds the gateway once on first request rather than per call. The incoming messages are UIMessage objects carrying an ordered parts array rather than a plain string, which is why you flatten them with convertToModelMessages before handing them to the model.
Confirm the route streams before building any interface. Run the dev server, then send a raw request.
curl -N -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"Say hello in one short sentence."}]}]}'
The endpoint answers with server-sent events, each a data: line carrying one typed chunk.

Each typed part runs a start, a series of deltas, and an end. This model streams a reasoning part first, then a text part, so you can render thinking separately from the answer. The deltas are fragments, not whole words, and that fragment stream is what your interface renders token by token.
useChat composable work in Vue?The useChat composable connects a Vue component to your streaming route and exposes the conversation as reactive state. Call it with no arguments, and it posts to /api/chat by default; hence it pairs with the route from the previous section without any transport configuration.
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import { ref } from 'vue'
const input = ref('')
const { messages, sendMessage, status } = useChat()
function handleSubmit(e: Event) {
e.preventDefault()
if (!input.value.trim()) return
sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<div v-for="(m, i) in messages" :key="m.id ?? i">
<strong>{{ m.role === 'user' ? 'You' : 'AI' }}</strong>
<div v-for="(part, j) in m.parts" :key="`${m.id}-${j}`">
<div v-if="part.type === 'reasoning'">{{ part.text }}</div>
<div v-else-if="part.type === 'text'">{{ part.text }}</div>
</div>
</div>
<form @submit="handleSubmit">
<input v-model="input" placeholder="Say something..." />
</form>
</template>
messages is a Vue ref that updates on every streamed chunk, so the template re-renders token by token as the response arrives. You iterate m.parts rather than a single string because each message holds an ordered array of typed parts, and rendering reasoning and text through separate branches is what lets you style a model’s thinking differently from its answer. sendMessage opens the stream, and status moves through submitted, streaming, and ready so you can drive loading states off it.

This renders the conversation but shows the model’s raw output, with Markdown headings, code fences, and tables arriving as literal characters since nothing here parses them. The next section replaces this hand-rolled markup with Nuxt UI’s chat components and streams that Markdown into formatted output as it lands.
Nuxt UI ships components built for AI chat that consume the AI SDK’s message parts directly, so you get a scrolling message list, a prompt with submit and stop handling, and dedicated blocks for reasoning and tool calls without writing that markup yourself. Comark renders the streamed Markdown, parsing tokens as they arrive so formatted output builds up live instead of flashing in at the end.
Install Nuxt UI, Comark, the Shiki languages, and Shiki itself. Comark’s highlight plugin depends on shiki, which pnpm won’t expose unless you declare it.
pnpm add @nuxt/ui @comark/nuxt @shikijs/langs shiki
Register both modules and point Nuxt at a CSS entry.
export default defineNuxtConfig({
compatibilityDate: '2026-07-31',
devtools: { enabled: true },
modules: ['@nuxt/ui', '@comark/nuxt'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
aiGatewayApiKey: ''
}
})
app/assets/css/main.css imports Tailwind and Nuxt UI, and app/app.vue wraps the app in UApp, which Nuxt UI needs for overlays and toasts.
@import "tailwindcss"; @import "@nuxt/ui";
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
Define a reusable Comark renderer with the highlight plugin, so assistant Markdown renders with syntax-highlighted code blocks. This becomes a <ChatComark> component you use in the page.
import highlight from '@comark/nuxt/plugins/highlight'
export default defineComarkComponent({
name: 'ChatComark',
plugins: [highlight()],
class: '*:first:mt-0 *:last:mb-0'
})
Now wire the page. UChatMessages renders the list and drives auto-scroll off status, and its #content slot hands you each message so you decide how each part renders. You branch on part type with the ai type guards, sending reasoning into UChatReasoning, assistant text into <ChatComark>, and user text into a plain paragraph, while isPartStreaming from @nuxt/ui/utils/ai tells a part whether it is still streaming so Comark can render incrementally.
<script setup lang="ts">
import { isReasoningUIPart, isTextUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming } from '@nuxt/ui/utils/ai'
const input = ref('')
const { messages, status, error, sendMessage, regenerate, stop } = useChat()
function onSubmit() {
if (!input.value.trim()) return
sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<UContainer class="min-h-dvh flex flex-col py-6">
<UChatMessages :messages="messages" :status="status" should-auto-scroll class="flex-1">
<template #content="{ message }">
<template v-for="(part, index) in message.parts" :key="`${message.id}-${part.type}-${index}`">
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part)"
>
<ChatComark :markdown="part.text" :streaming="isPartStreaming(part)" />
</UChatReasoning>
<template v-else-if="isTextUIPart(part)">
<ChatComark
v-if="message.role === 'assistant'"
:markdown="part.text"
:streaming="isPartStreaming(part)"
/>
<p v-else-if="message.role === 'user'" class="whitespace-pre-wrap">
{{ part.text }}
</p>
</template>
</template>
</template>
</UChatMessages>
<UChatPrompt v-model="input" :error="error" class="sticky bottom-0" @submit="onSubmit">
<UChatPromptSubmit :status="status" @stop="stop()" @reload="regenerate()" />
</UChatPrompt>
</UContainer>
</template>
UChatPromptSubmit swaps between send, stop, and reload based on status, wiring stop mid-stream and regenerate to retry. Your reasoning now renders in a collapsible block, and the answer streams in as formatted Markdown with a real table and highlighted code, none of which you maintain.

The Gateway route reaches hosted models behind an API key. To run inference at the edge instead, you bind Cloudflare Workers AI directly, so the model runs on Cloudflare’s network with no third-party model account. Because the workers-ai-provider implements the same AI SDK interface, the swap touches only the provider and the model ID, leaving the stream, the composables, and the Nuxt UI components exactly as they are.
Install the provider and Wrangler.
pnpm add workers-ai-provider pnpm add -D wrangler
Declare the AI binding in wrangler.jsonc, along with the Worker entry and static assets the Nitro Cloudflare build produces. Workers AI has no local emulation, so the binding always runs against Cloudflare’s network, even in development.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "nuxt-ai-2026",
"compatibility_date": "2026-07-31",
"compatibility_flags": ["nodejs_compat"],
"main": ".output/server/index.mjs",
"assets": {
"directory": ".output/public",
"binding": "ASSETS"
},
"ai": {
"binding": "AI"
}
}
Point Nitro at the Cloudflare Workers preset so the build targets that runtime.
export default defineNuxtConfig({
compatibilityDate: '2026-07-31',
devtools: { enabled: true },
modules: ['@nuxt/ui', '@comark/nuxt'],
css: ['~/assets/css/main.css'],
nitro: {
preset: 'cloudflare_module'
},
runtimeConfig: {
aiGatewayApiKey: ''
}
})
Your edge route mirrors the Gateway route, swapping the provider. createWorkersAI takes the AI binding, which the Cloudflare preset exposes on event.context.cloudflare.env.
import {
streamText,
convertToModelMessages,
createUIMessageStreamResponse,
toUIMessageStream,
type UIMessage
} from 'ai'
import { createWorkersAI } from 'workers-ai-provider'
export default defineEventHandler(async (event) => {
const { messages }: { messages: UIMessage[] } = await readBody(event)
const workersAI = createWorkersAI({ binding: event.context.cloudflare.env.AI })
const result = streamText({
model: workersAI('@cf/meta/llama-3.2-3b-instruct'),
messages: await convertToModelMessages(messages)
})
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream })
})
})
Use a current Workers AI text-generation model, since Cloudflare deprecates older ones. List what your account can run with npx wrangler ai models, and pick a text-generation entry.
To run on the Cloudflare runtime locally, build first, then start wrangler dev, which serves the built Worker with the real binding.
pnpm build npx wrangler dev
Confirm the route streams the same shape as before.
curl -N -X POST http://localhost:8787/api/chat-edge \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"Say hello in one short sentence."}]}]}'

The frame sequence matches the Gateway route exactly, so Nuxt UI’s components render this stream with no changes. The one difference is content, not shape: this model emits only text parts, since Llama 3.2 3B does not produce reasoning, so the UChatReasoning branch never fires.
With the Cloudflare preset and the wrangler.jsonc bindings already in place, you deploy with a single command. Wrangler builds on the output from pnpm build, uploads the Worker and its static assets, and attaches the declared bindings.
npx wrangler deploy
It reports the uploaded assets, the attached bindings, and a live URL on your workers.dev subdomain.
✨ Success! Uploaded 115 files Your Worker has access to the following bindings: Binding Resource env.AI AI env.ASSETS Assets Deployed nuxt-ai-2026 triggers https://nuxt-ai-2026.<your-subdomain>.workers.dev
Your AI binding carries over from local development to production unchanged, so the deployed edge route runs the same Workers AI inference without further configuration. Confirm the live endpoint streams by hitting it directly.
curl -N -X POST https://nuxt-ai-2026.<your-subdomain>.workers.dev/api/chat-edge \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"Say hello in one short sentence."}]}]}'
You get the same typed stream as the local run, so the interface behaves identically in production.

You can watch requests hit the deployed Worker in real time with npx wrangler tail nuxt-ai-2026, which prints each invocation and any server-side errors. A fresh deploy can take a few seconds to propagate across the edge, so a request fired immediately after deploy may briefly return a Cloudflare error before the Worker is live everywhere.
Both routes deploy to the same Worker, so a benchmark against them carries identical network distance and isolates the difference to the inference path. The harness fires ten requests at each route, records time to first token and throughput after the first token, and reports the median across runs. Time to first token captures connection and model spin-up, while post-first-token throughput reflects generation speed once the stream is flowing.
const BASE = 'https://nuxt-ai-2026.<your-subdomain>.workers.dev'
const PROMPT = 'Explain what a hash map is in three sentences.'
async function measure(route) {
const start = performance.now()
let firstTokenAt = null
let tokenCount = 0
const res = await fetch(`${BASE}${route}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ id: '1', role: 'user', parts: [{ type: 'text', text: PROMPT }] }]
})
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value).split('\n')) {
if (line.includes('"type":"text-delta"')) {
if (firstTokenAt === null) firstTokenAt = performance.now()
tokenCount++
}
}
}
const end = performance.now()
return {
ttft: firstTokenAt - start,
tps: tokenCount / ((end - firstTokenAt) / 1000)
}
}
The measured medians, taken against the deployed Worker:
| Route | Model | TTFT median | Throughput median |
|---|---|---|---|
| Gateway | ling-3.0-flash-free |
1943 ms | 103.5 tok/s |
| Workers AI edge | llama-3.2-3b-instruct |
498 ms | 504.0 tok/s |
The edge route reaches its first token roughly four times faster, but this is not a clean edge-versus-hosted result, since the Gateway model is a reasoning model that thinks before answering while the edge model is a small instruct model that emits text immediately, so part of the gap is model behavior rather than transport.

The Vue and Nuxt ecosystem now ships everything a production AI feature needs, from the @ai-sdk/vue composables to Nuxt UI’s chat components to a Workers AI binding that runs inference at the edge. Swapping the hosted Gateway for the edge binding changed one provider line and one model ID, since both implement the same AI SDK interface, and the same components rendered both streams without a single edit. What used to be a React-only story is a one-command deploy in Nuxt.
You can find the complete source code on GitHub. Happy coding!

Learn how to build a streaming AI chat app in Nuxt using the Vercel AI SDK, Nuxt UI chat components, and Cloudflare Workers AI for edge inference.

Meta’s Astryx ships an MCP server and component manifest so agents query real APIs. We tested it against shadcn/ui with Claude Code across three real UI builds.

Ten AI agent skills that solve real productivity problems for senior engineers, install commands, use cases, and when each one isn’t worth the overhead.

Google holds vast training data, an evolving agentic AI IDE called Antigravity, and AdSense billions. Here’s why it’s likely to dominate the AI race.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now