resumableChat
The POST, GET and DELETE trio, so a reload halfway through an answer picks the stream back up.
A reload halfway through an answer normally loses it: the stream was tied to a request that no longer exists. resumableChat takes every chatHandler option and returns the three handlers a resuming client needs.
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { store } from "@/lib/threads";
export const { POST } = resumableChat({
store,
execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
});// app/api/chat/[id]/stream/route.ts - the path the SDK's client resumes from
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { store } from "@/lib/threads";
export const { GET, DELETE } = resumableChat({
store,
execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
});Splitting the handlers across two route files works because the default stream context is a single process-wide instance.
Then turn resuming on in the client:
"use client";
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";
export function Chat({ id, initialMessages }: { id: string; initialMessages: UIMessage[] }) {
const { messages, sendMessage } = useChat({ id, messages: initialMessages, resume: true });
return <>{/* your UI */}</>;
}The three handlers
| Handler | Behaviour |
|---|---|
POST | chatHandler, plus: records a stream id on the thread before answering, and clears it once the stream ends. |
GET | Replays a stream that is still in flight. 204 when there is nothing to resume, which is what the client expects. |
DELETE | Forgets the active stream, so a later GET answers 204. Always 204. |
authorize runs on GET and DELETE too, not just POST - otherwise anyone with a thread id could read another user's in-flight reply.
Extra options
| Option | Required | What it does |
|---|---|---|
streamContext | no | Where in-flight streams live. Defaults to in-process - see below. |
threadIdFrom | no | How to read the thread id from a GET/DELETE. Defaults to the <threadId>/stream path the client uses. |
The default context is in-process
That is a real limitation, not a detail. It resumes only within the instance that served the POST, so on more than one instance - or any serverless deployment - a resume can land on a process that never saw the stream and gets a 204. Pass a Redis-backed context for those:
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { createResumableStreamContext } from "resumable-stream/ioredis";
import { store } from "@/lib/threads";
export const { POST, GET, DELETE } = resumableChat({
store,
execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
streamContext: createResumableStreamContext({ waitUntil: null }),
});resumable-stream is an optional peer, so add it when you use this module: npm install resumable-stream. Redis on top of that is your choice, not this package's - resumable-stream itself ships with no dependencies.
DELETE forgets, it does not abort
resumable-stream exposes no abort, so the generation finishes server-side - and is still persisted - but stops being resumable. That is what a stop button wants in practice: the answer is saved, the client stops following it.
Migration
This module needs one nullable column, which the schema already includes:
ALTER TABLE ai_sdk_threads ADD COLUMN active_stream_id text;