Skip to content

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 ​

  1. Server-Side Rendering (SSR) - The page server-renders the committed conversation from one snapshot
  2. Stream Resumption - A page refresh mid-stream resumes exactly where the snapshot's cursor ends
  3. 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
  4. Status-Based UI - The snapshot status (active, paused, ended, failed) decides whether to resume
  5. 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 ​

mermaid
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"]
    end

Project 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), ChatErrorBanner

Key Implementation Details ​

Server handler ​

lib/handler.ts wires handleChatStream and buildSnapshot to a Redis state store and stream manager:

typescript
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 ​

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

StatusMeaningClient action
activeThe current run is live (even before its stream is live)Resume from streamSequence
pausedThe run is suspended (a client tool or an approval)Resume; the stream continues when the run does
endedThe run finished, or was supersededNo resume
failedThe run or its stream failedShow 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 ​

  1. Start a new chat and ask a question
  2. While the agent is "thinking" (after a tool call starts), refresh the page
  3. 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 ​

bash
# 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:3000

Released under the MIT License.