| name | vercel-ai-sdk |
| description | Vercel AI SDK integration with Durable Streams. createDurableChatTransport() for useChat(), toDurableStreamResponse() for server-side streaming, resumable chat sessions with reconnectToStream(), read proxy pattern for auth. Load when building chat apps with Vercel AI SDK (@ai-sdk/react) and durable streams.
|
| type | composition |
| library | durable-streams |
| library_version | 0.2.1 |
| requires | ["getting-started"] |
| sources | ["durable-streams/durable-streams:packages/aisdk-transport/src/client.ts","durable-streams/durable-streams:packages/aisdk-transport/src/server.ts","durable-streams/durable-streams:packages/aisdk-transport/src/types.ts"] |
This skill builds on durable-streams/getting-started. Read it first for setup and offset basics.
Durable Streams — Vercel AI SDK
Drop-in transport for useChat() that writes AI responses to durable streams.
Chat sessions survive page refreshes and can be resumed mid-generation.
Setup
Client
import { useMemo } from "react"
import { useChat } from "@ai-sdk/react"
import { createDurableChatTransport } from "@durable-streams/aisdk-transport"
function Chat({ id, initialMessages }) {
const transport = useMemo(
() => createDurableChatTransport({ api: "/api/chat" }),
[]
)
const { messages, sendMessage, status } = useChat({
id,
messages: initialMessages,
transport,
resume: true,
})
}
Server — POST /api/chat
import { streamText, convertToModelMessages } from "ai"
import { toDurableStreamResponse } from "@durable-streams/aisdk-transport"
export async function POST(request: Request) {
const { messages, id } = await request.json()
const result = streamText({
model: openai("gpt-4o-mini"),
messages: await convertToModelMessages(messages),
})
const streamPath = `chat/${id}/${crypto.randomUUID()}`
await saveChat({ id, activeStreamId: streamPath })
return toDurableStreamResponse({
source: result.toUIMessageStream({
originalMessages: messages,
onFinish: ({ messages: finalMessages }) => {
void saveChat({ id, messages: finalMessages, activeStreamId: null })
},
}),
stream: {
writeUrl: buildWriteStreamUrl(streamPath),
readUrl: buildReadProxyUrl(request, streamPath),
headers: WRITE_HEADERS,
},
})
}
mode: "immediate" (default) returns 201 immediately; writes continue in background. Use mode: "await" when the runtime needs an active request to keep running.
Reconnect endpoint — GET /api/chat/:id/stream
Required for resume: true. Returns the active stream URL or 204 if no generation is in flight:
export async function GET(request, { params }) {
const { id } = await params
const chat = await loadChat(id)
if (!chat?.activeStreamId) {
return new Response(null, { status: 204 })
}
const streamUrl = buildReadProxyUrl(request, chat.activeStreamId)
return Response.json(
{ streamUrl },
{ status: 200, headers: { Location: streamUrl } }
)
}
The transport defaults to ${api}/${chatId}/stream. Pass reconnectApi to override.
Read proxy
Always proxy reads through an app route so write credentials stay server-side. Pass the proxy URL as readUrl in toDurableStreamResponse().
function copyHeaders(response: Response): Headers {
const headers = new Headers()
for (const [key, value] of response.headers.entries()) {
const k = key.toLowerCase()
if (
k === "connection" ||
k === "transfer-encoding" ||
k === "content-encoding" ||
k === "content-length"
)
continue
headers.set(key, value)
}
headers.set("Cache-Control", "no-store")
return headers
}
export async function GET(request: Request) {
const url = new URL(request.url)
const streamPath = url.searchParams.get("path")
if (!streamPath)
return Response.json({ error: "Missing stream path" }, { status: 400 })
const upstreamUrl = new URL(buildReadStreamUrl(streamPath))
for (const [key, value] of url.searchParams) {
if (key === "path") continue
upstreamUrl.searchParams.append(key, value)
}
const response = await fetch(upstreamUrl, {
headers: {
Authorization: `Bearer ${process.env.DS_SECRET}`,
...(request.headers.get("accept")
? { Accept: request.headers.get("accept")! }
: {}),
},
})
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: copyHeaders(response),
})
}
Common Mistakes
CRITICAL Not persisting activeStreamId for resume
Save the active stream path before returning and clear it in onFinish. Without it, the reconnect endpoint has nothing to return and resume: true silently fails. See the server setup above for the correct pattern.
CRITICAL Exposing write URLs to the client
Wrong: omitting readUrl — defaults to writeUrl, leaking credentials in the Location header.
Fix: always set readUrl to a read proxy route.
Source: packages/aisdk-transport/src/server.ts
HIGH Not using waitUntil on serverless runtimes
In immediate mode, the response returns before writes finish. Without waitUntil, serverless runtimes may kill the process and drop chunks.
Fix: pass waitUntil: ctx.waitUntil.bind(ctx) to toDurableStreamResponse().
Source: packages/aisdk-transport/src/server.ts
HIGH Not clearing activeStreamId on finish
A stale activeStreamId causes the reconnect endpoint to return a completed stream. Always clear it in onFinish. See the server setup above.
MEDIUM Missing reconnect endpoint
resume: true calls GET ${api}/${chatId}/stream on mount. If this endpoint doesn't exist, reconnection fails silently with a 404. See the reconnect endpoint setup above.
Source: packages/aisdk-transport/src/client.ts
See also
Version
Targets @durable-streams/aisdk-transport v0.2.1.