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.

MC
Martin Coll

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 format useChat sends: messages made of typed parts. convertToModelMessages turns them into the model-level format streamText wants. In ai@6 it returns a promise, so await it.
  • pipeUIMessageStreamToResponse is a method on the streamText result and takes a Node ServerResponse, 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: no

x-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 only user and assistant messages; a system message is rejected.
  • model must 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.write returns false it waits for drain.

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.