Resumable Streams with Next.js
This example demonstrates resumable streams with Helix Agents using Next.js App Router, Redis persistence, and the JS runtime executor.
Runnable Example: The complete source code is available in the monorepo at
examples/resumable-streams-nextjs/.
Deployment Mode: Direct (in-process stores). For edge deployment with Durable Objects, see OpenNext + Cloudflare DO.
What This Demonstrates
- Server-Side Rendering (SSR) - The page server-renders the committed conversation from one snapshot
- Stream Resumption - A page refresh mid-stream resumes exactly where the snapshot's cursor ends
- Checkpoint-anchored snapshots - A snapshot is one consistent read: the committed messages up to a checkpoint and that checkpoint's stream cursor, so a reload never loses or duplicates content
- Status-Based UI - The snapshot
status(active,paused,ended,failed) decides whether to resume - Resync and recovery -
useHelixChat({ resync })re-anchors on the latest snapshot when the stream tells it to (a rollback, a crash recovery, a superseded attempt) or when a resume fails
Architecture
graph TB
subgraph Server ["Server Component (RSC)"]
S1["1. loadSnapshot(): buildSnapshot() with retry on a 5xx"]
S2["2. Pass the snapshot (or the typed error) as props"]
end
Server -->|"Props: { load }"| Client
subgraph Client ["Client Component ('use client')"]
C1["1. snapshotResumeOptions(snapshot)"]
C2["2. Seed messages WITHOUT the in-flight message"]
C3["3. If active / paused: resume from streamSequence under resumeMessageId (replayPrefix)"]
C4["4. Server replays the in-flight message's committed prefix, then the tail"]
endProject Structure
src/
├── app/
│ ├── page.tsx # Home page
│ ├── chat/[sessionId]/
│ │ ├── page.tsx # Server component: loadSnapshot() + SSR
│ │ └── ChatClient.tsx # Client component: snapshotResumeOptions + useHelixChat
│ └── api/chat/[sessionId]/
│ ├── route.ts # POST (send / submit) and GET (resume) via handleChatStream
│ └── snapshot/route.ts # GET snapshot with typed 404 / 5xx bodies
├── lib/
│ ├── agent.ts # Agent definition
│ ├── handler.ts # handleChatStream + buildSnapshot over Redis
│ └── snapshot-load.ts # Server-side snapshot read with retry
└── components/ # SnapshotGate (error state + Retry), ChatErrorBannerKey Implementation Details
Server handler
lib/handler.ts wires handleChatStream and buildSnapshot to a Redis state store and stream manager:
import { handleChatStream, buildSnapshot } from '@helix-agents/ai-sdk';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);
export const handleChat = (sessionId: string, messages: readonly UIMessage[], request?: Request) =>
handleChatStream(
{ executor, stateStore, streamManager, agent: ResearchAgent },
{ sessionId, messages, request } // the request carries the resume headers
);
export const getSnapshot = (sessionId: string) =>
buildSnapshot<AgentState>({ stateStore, streamManager }, { sessionId });The snapshot route answers snapshotNotFoundResponse() (404 state_session_not_found) for a missing session and snapshotErrorResponse(error) (a typed 5xx { error, code, cause? }) for a failed read, never an empty conversation. The page's loadSnapshot retries a failed read (250 / 500 / 1000 ms) and renders an explicit error state with a Retry control after exhaustion; only a 404 becomes the empty new-session snapshot.
Client-side resume
const opts = useMemo(() => snapshotResumeOptions(initialSnapshot), [initialSnapshot]);
const transport = new DefaultChatTransport({
api,
prepareSendMessagesRequest: prepareHelixChatRequest({ api }),
prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
api,
resumeFromSequence: opts.resumeFromSequence,
existingMessageId: opts.existingMessageId,
replayPrefix: opts.replayPrefix,
}),
});
const { messages, sendMessage, retry, resyncError } = useHelixChat({
id: sessionId,
transport,
messages: opts.messages, // the snapshot WITHOUT the in-flight message
resume: opts.resume, // true only for an active / paused snapshot
resync: { snapshotUrl: `/api/chat/${sessionId}/snapshot`, snapshot: initialSnapshot },
});snapshotResumeOptions seeds the chat without the in-flight message and resumes with X-Existing-Message-Id and X-Helix-Replay-Prefix: 1. The server reads one snapshot, replays that message's committed prefix between transient data-helix-replay markers, then streams the tail after that snapshot's cursor, so the rebuilt message is char-exact on every ai 6.x version. The error banner's Retry calls retry(), which re-attaches after a failed resume and re-sends a failed send only when the snapshot does not hold the turn yet. See Resumable Streams for the full contract.
How It Works
Snapshot status
| Status | Meaning | Client action |
|---|---|---|
active | The current run is live (even before its stream is live) | Resume from streamSequence |
paused | The run is suspended (a client tool or an approval) | Resume; the stream continues when the run does |
ended | The run finished, or was superseded | No resume |
failed | The run or its stream failed | Show the error; retry() |
Resync
When a rewind, a crash recovery or a superseded LLM attempt changes what the client already rendered, the stream carries a data-stream-resync or data-attempt-superseded event. useHelixChat({ resync }) then stops the stream, fetches the latest snapshot and resumes from its cursor. A resume that fails (409, 503, network) re-anchors the same way, restoring the hidden in-flight message.
Testing Stream Resumption
- Start a new chat and ask a question
- While the agent is "thinking" (after a tool call starts), refresh the page
- Observe that:
- Messages are immediately visible (SSR)
- The stream continues from where it left off
- No events are lost
Quick Start
Prerequisites
- Node.js 22+
- Docker (for Redis)
- OpenAI API key
Setup
# Start Redis
docker-compose up -d
# Install dependencies
pnpm install
# Configure environment
cp .env.example .env
# Edit .env and add your OpenAI API key
# Run the development server
pnpm run dev
# Open http://localhost:3000Related Documentation
- AI SDK Integration - Full frontend integration guide
- Streaming Guide - Stream chunk types and patterns
- Interrupt & Resume - Crash recovery concepts