Skip to content
Transports

HTTP Stream

httpStream() reads newline-delimited JSON: one StreamChunk per line. Use it when SSE is blocked.

Quick setup

1. Return NDJSON from the server

Wrap the chat() stream with toHttpResponse():

ts
// 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);
}

2. Connect from the client

Pass fetchHttpStream() to useChat:

tsx
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.

How it works

  1. sendMessage posts the messages to /api/chat.
  2. toHttpResponse() writes each chunk as JSON.stringify(chunk) + "\n".
  3. fetchHttpStream() splits the body on newlines and parses each line into a chunk.

To write the response yourself, write each chunk in that same line format to the response body.

Advanced

Resume a dropped stream

Pass a durability adapter to toHttpResponse. Then each line becomes an { id, chunk } envelope, and the client can continue a reply:

  • Reconnect. A dropped connection reconnects with Last-Event-ID. The transport removes the replayed chunks that it already has.
  • joinRun(runId). It attaches to a run that already exists.

Add a GET handler for joinRun, with resumeHttpResponse:

ts
// 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.