httpStream() reads newline-delimited JSON: one StreamChunk per line. Use it when SSE is blocked.
Wrap the chat() stream with toHttpResponse():
// api/chat.ts
import { chat, chatParamsFromRequest, toHttpResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
});
return toHttpResponse(stream);
}Pass fetchHttpStream() to useChat:
import { useChat, fetchHttpStream } from "@tanstack/ai-react";
export function Chat() {
const { messages, sendMessage } = useChat({
connection: fetchHttpStream("/api/chat"),
threadId: "support-chat",
});
return (
<div>
{messages.map((message) => (
<p key={message.id}>
{message.parts.map((part) =>
part.type === "text" ? part.content : "",
)}
</p>
))}
<button onClick={() => void sendMessage("Hello")}>Send</button>
</div>
);
}Click Send. Each chunk arrives as one JSON line, and the reply streams into messages.
fetchHttpStream takes the same options as fetchServerSentEvents: headers, body, credentials, fetchClient, and dynamic functions. See Request Options.
On React Native, use xhrHttpStream with the same server. See React Native.
To write the response yourself, write each chunk in that same line format to the response body.
Pass a durability adapter to toHttpResponse. Then each line becomes an { id, chunk } envelope, and the client can continue a reply:
Add a GET handler for joinRun, with resumeHttpResponse:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
memoryStream,
resumeHttpResponse,
toHttpResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
});
return toHttpResponse(stream, {
durability: { adapter: memoryStream(request) },
});
}
// joinRun hits GET ?offset=-1&runId=... (replay only, no messages sent).
export async function GET(request: Request) {
return resumeHttpResponse({ adapter: memoryStream(request) });
}The client from step 2 needs no change. xhrHttpStream resumes the same way.
The guarantees are the same as resumable SSE, over NDJSON. To pick a durability adapter for production, see Resumable Streams.