Vercel AI SDK data stream protocol from an Express server
Stream an LLM from Express with the Vercel AI SDK data stream protocol: streamText, pipeUIMessageStreamToResponse, the SSE chunks and the useChat client.
The Vercel AI SDK data stream protocol is a Server-Sent Events stream where every
event is data: followed by one JSON chunk (text-start, text-delta,
text-end, finish...), the response carries the header
x-vercel-ai-ui-message-stream: v1, and the stream ends with data: [DONE]. From
Express you do not write any of that by hand: call streamText(...) and pass the
Express response to pipeUIMessageStreamToResponse(res). This guide shows that
server, the exact bytes it sends, the useChat client that reads it, and what
useDeploy does differently. Versions: [email protected], @ai-sdk/react@3, Express
4.22. The protocol is described in the AI SDK Stream Protocols
docs, where the Data Stream
Protocol is a section of the page; the same section also calls it the UI message
stream.
The server
import express from 'express';
import { anthropic } from '@ai-sdk/anthropic';
import { streamText, convertToModelMessages, type UIMessage } from 'ai';
const app = express();
app.use(express.json());
app.post('/api/chat', async (req, res) => {
const { messages }: { messages: UIMessage[] } = req.body;
// Stop paying for tokens when the browser tab closes.
const abort = new AbortController();
res.on('close', () => {
if (!res.writableFinished) abort.abort();
});
const result = streamText({
model: anthropic('claude-haiku-4-5'),
messages: await convertToModelMessages(messages),
abortSignal: abort.signal,
});
result.pipeUIMessageStreamToResponse(res);
});
app.listen(3005);Three things to notice:
- The request body is
UIMessage[], the formatuseChatsends: messages made of typedparts.convertToModelMessagesturns them into the model-level formatstreamTextwants. Inai@6it returns a promise, soawaitit. pipeUIMessageStreamToResponseis a method on thestreamTextresult and takes a NodeServerResponse, which is what Express hands you. It sets the status and headers, and writes and ends the response.- The abort is yours to wire. Without it a closed tab keeps the upstream call alive until the model finishes.
What goes over the wire
We ran this route against a mock model so the output is deterministic. The headers:
content-type: text/event-stream
cache-control: no-cache
connection: keep-alive
x-vercel-ai-ui-message-stream: v1
x-accel-buffering: nox-accel-buffering: no tells nginx not to buffer the stream, which is the usual
reason streaming works locally and arrives all at once in production. The body:
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"t1"}
data: {"type":"text-delta","id":"t1","delta":"Hello"}
data: {"type":"text-delta","id":"t1","delta":" world"}
data: {"type":"text-end","id":"t1"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]The docs list start, text-start, text-delta, text-end, finish, error
(with an errorText) and the [DONE] terminator. streamText also emits step
boundaries (start-step, finish-step) around each model call, which matter once
you add tools.
The client
'use client';
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
export function Chat() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: 'http://localhost:3005/api/chat',
credentials: 'include', // send the session cookie to the API origin
}),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
void sendMessage({ text: input });
setInput('');
}}
>
{messages.map((m) => (
<p key={m.id}>
{m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))}
</p>
))}
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={status !== 'ready'} />
</form>
);
}useChat speaks the data stream protocol by default. If the API is on another
origin, add CORS with credentials on the Express side.
Writing the protocol yourself
If your stream does not come from streamText, for example a RAG pipeline that
emits its own text, build chunks directly. This produces a valid response the same
client can read:
import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai';
const stream = createUIMessageStream({
execute: ({ writer }) => {
writer.write({ type: 'text-start', id: 'a' });
writer.write({ type: 'text-delta', id: 'a', delta: 'Hi' });
writer.write({ type: 'text-end', id: 'a' });
},
});
pipeUIMessageStreamToResponse({ response: res, stream });If you only need plain text and no tool calls or structured parts, the docs also
describe a text stream protocol. The SDK exposes result.pipeTextStreamToResponse(res)
for it on the server, and TextStreamChatTransport on the client.
What useDeploy ships instead
useDeploy's POST /api/v1/ai/chat uses the AI SDK but does not emit the data
stream protocol. The provider adapter in packages/ai/src/providers/anthropic-llm.ts
calls streamText(...) and returns result.textStream, an async iterable of
strings. The controller in
apps/server/src/modules/ai/interfaces/http/ai.controller.ts writes those deltas
as its own SSE events:
event: delta
data: {"text":"Hello"}
event: done
data: {}An error event carries {"message": "..."}. The dashboard client reads this
with a small fetch stream parser, and useChat is not wired to it.
We made that choice for control over what an endpoint that spends money accepts:
- The request body is validated by a schema from
@app/contracts. Clients can send onlyuserandassistantmessages; asystemmessage is rejected. modelmust be the configured default or on an allowlist, and output is capped by a token limit, both checked before any upstream call.- The controller aborts the upstream stream when the client disconnects, when a write fails, and after a wall-clock timeout (120 seconds by default), because under Bun a disconnect event is not always delivered for a consumed POST.
- It respects backpressure: if
res.writereturnsfalseit waits fordrain.
The trade-off is real. Plain text deltas carry no tool calls, reasoning parts or
structured data, so if you want those you have to change two things. Change the ILlmProvider
port to return the full streamText result instead of textStream, and change
the @app/contracts body to accept UIMessage[] (or map it in
prepareSendMessagesRequest on the client). Then switch the controller to
pipeUIMessageStreamToResponse and the page to useChat. For text only, you can
keep the port and wrap the deltas with createUIMessageStream
(text-start, text-delta, text-end). The behavior and environment
variables are in the AI docs.
Frequently asked questions
Is the data stream protocol the same as the UI message stream protocol?
Yes. The Stream Protocols page of the AI SDK docs has a "Data Stream Protocol"
section and describes it as the UI message stream; the header name is x-vercel-ai-ui-message-stream.
Do I need Next.js?
No. pipeUIMessageStreamToResponse takes a Node ServerResponse. In a framework
that works with Web Response objects, use toUIMessageStreamResponse().
Why does my stream arrive all at once behind a proxy?
A proxy is buffering it. Keep x-accel-buffering: no and
cache-control: no-cache, and disable response compression on the route.
How do I send an error mid-stream?
The protocol has an error chunk with errorText. The SDK emits it when the
model call fails after the stream started, since the status code is already sent.
useDeploy ships the guarded streaming endpoint, the provider port and the dashboard chat page. See the AI feature.