fetchJson returns the whole run as one JSON body. useChat still renders messages, tool calls, approvals, and errors.
Run the chat to its end with stream: false. Then send the result with toJsonResponse():
// api/chat.ts
import { chat, chatParamsFromRequest, toJsonResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const result = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
stream: false,
});
return toJsonResponse(result);
}Pass fetchJson() to useChat:
import { useChat, fetchJson } from "@tanstack/ai-react";
export function Chat() {
const { messages, sendMessage, isLoading } = useChat({
connection: fetchJson("/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 disabled={isLoading} onClick={() => void sendMessage("Hello")}>
Send
</button>
</div>
);
}Click Send. When the run ends, the full reply appears in messages, with no stream between your server and the browser.
fetchJson sends the same POST body as fetchHttpStream. It takes the same options: headers, body, credentials, fetchClient, and dynamic functions. See Request Options.
You can also pass the stream from chat() to toJsonResponse(). Then pass request.signal, so that the run stops when the client leaves:
// api/chat.ts
import { chat, chatParamsFromRequest, toJsonResponse } 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 toJsonResponse(stream, { signal: request.signal });
}await chat({ outputSchema }) gives you the typed object on the server. You can save it or change it before the client sees it. To show it in useChat, pass the object to toJsonResponse():
// api/extract-person.ts
import { chat, chatParamsFromRequest, toJsonResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
});
export async function POST(request: Request) {
const { messages, threadId } = await chatParamsFromRequest(request);
const person = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
outputSchema: PersonSchema,
});
// `person` is typed here. Save it before you send it.
return toJsonResponse(person);
}On the client, give useChat the same schema and read final:
import { useChat, fetchJson } from "@tanstack/ai-react";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
});
export function PersonExtractor() {
const { sendMessage, isLoading, final } = useChat({
connection: fetchJson("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<div>
<button
disabled={isLoading}
onClick={() => void sendMessage("Extract: John Doe is 30")}
>
Extract
</button>
{final && (
<p>
{final.name} is {final.age}
</p>
)}
</div>
);
}What happens:
To fill in the object field by field, pass the stream from chat({ outputSchema, stream: true }) to toJsonResponse() instead.
A long agent run can take more time than your host lets one request live. Add durability, and the server replies before the timeout. The client then asks for the rest.
// api/chat.ts
import {
chat,
chatParamsFromRequest,
memoryStream,
resumeJsonResponse,
toJsonResponse,
} 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 toJsonResponse(stream, {
durability: { adapter: memoryStream(request) },
signal: request.signal,
maxWaitMs: 10_000,
});
}
// fetchJson polls GET ?runId=...&offset=... until the reply has done: true.
export async function GET(request: Request) {
return resumeJsonResponse({ adapter: memoryStream(request) });
}What happens:
resumeJsonResponse waits at most 1000 ms for new chunks before it replies. It never calls the model.
The client from step 2 needs no change. To set the wait between polls (default 250 ms), pass pollIntervalMs:
import { fetchJson } from "@tanstack/ai-react";
export const connection = fetchJson("/api/chat", { pollIntervalMs: 500 });Pass this connection to useChat.
memoryStream(request) keeps the log in one process. If your requests run on many processes, use a production adapter. See Resumable Streams.