← Blog

Claude API Streaming With Server Actions

2026-10-16 · 5 min read · SubToAPI Team

If you're trying to stream Claude's token-by-token output through a Next.js Server Action and hitting a wall, you're not imagining it: Server Actions were not built as a transport for long-lived streams. They're RPC calls — the client invokes a function on the server, and that function returns a single resolved value (or throws). That design clashes with how streaming APIs like Claude's work, where the server pushes partial chunks over time via Server-Sent Events.

The short answer: you don't stream through a Server Action in the HTTP sense. You either (1) use a Route Handler for the actual SSE connection and call it from the client, or (2) use React's built-in streaming primitives (Suspense + async generators) to push progressive UI updates that are triggered by a Server Action but rendered as a stream. Below is how each approach actually works, and when to use which.

Why Server Actions Can't Stream Raw Tokens

A Server Action is compiled into a POST request under the hood, but the framework wraps the response in RSC serialization. It waits for the action to resolve before sending anything meaningful back to the client. Even if your action internally reads a ReadableStream from Claude's API, the browser won't see incremental chunks the way it would with fetch against a plain API route — the action's return value is the unit of transport.

This is a common trap for teams migrating off direct fetch calls into the "everything is a Server Action" pattern. It works fine for non-streaming completions, but breaks the moment you want a typing-indicator-style UI.

Option 1: Stream Through a Route Handler

This is the most reliable approach and the one most production apps use. Keep the streaming connection in a Route Handler (app/api/chat/route.ts), and call it from the client with fetch, reading the body as a stream.

// app/api/chat/route.ts
export async function POST(req: Request) {
  const { messages } = await req.json();

  const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "claude-sonnet-4",
      max_tokens: 1024,
      stream: true,
      messages,
    }),
  });

  return new Response(upstream.body, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

On the client, read the stream directly:

const res = await fetch("/api/chat", {
  method: "POST",
  body: JSON.stringify({ messages }),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  // parse SSE lines and append tokens to UI state
}

This pattern preserves true byte-level streaming and works identically whether you call the Claude API directly or route through SubToAPI, which exposes the same streaming contract at /v1/messages with "stream": true — see /docs/streaming for the exact event format and chunk shapes.

Option 2: Simulate Streaming From a Server Action

If your architecture is Server-Action-first and you want to avoid adding Route Handlers, you can still get progressive rendering using React's async generator support with Suspense. The Server Action kicks off the request and returns a value wrapped in a stream-friendly primitive (commonly done with the ai SDK's createStreamableValue, or a hand-rolled async generator passed to a Server Component).

// app/actions.ts
"use server";

export async function* streamReply(prompt: string) {
  const res = await fetch("https://api.subtoapi.app/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "claude-sonnet-4",
      max_tokens: 1024,
      stream: true,
      messages: [{ role: "user", content: prompt }],
    }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n").filter(Boolean);
    for (const line of lines) {
      if (line.startsWith("data:")) {
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") return;
        const json = JSON.parse(payload);
        if (json.delta?.text) yield json.delta.text;
      }
    }
    buffer = "";
  }
}

A Client Component consumes this with for await...of, updating state per yielded chunk. This gives you a streaming-feeling UI without a separate Route Handler, but it's heavier to maintain and has rougher edge cases around aborting mid-stream (cancelling a Server Action generator cleanly is less mature than aborting a fetch). For most teams, Option 1 is simpler to debug and reason about.

Handling Backpressure and Partial JSON

Whichever approach you choose, don't assume each chunk is a complete JSON object. SSE frames can split across TCP packets, so always buffer and split on \n\n boundaries before parsing. If you're building this against SubToAPI, the same event structure documented in /docs/messages applies to streamed responses — content_block_delta events carry the incremental text, and a final message_stop event closes things out.

For tool use combined with streaming (common in agentic UIs), tool call arguments also arrive incrementally. Check /docs/tools if your Server Action or Route Handler needs to assemble a tool call before executing it client-side or server-side.

Where SubToAPI Fits

If you're already paying for Claude through a subscription and want a stable HTTPS endpoint with API keys, streaming, and usage metadata for exactly this kind of integration, SubToAPI gives you the sub_live_... key and the same /v1/messages streaming contract shown above — no separate Anthropic API billing setup. Start with /docs/quickstart, and when you're ready, /signup includes a free trial before you pick a plan on /pricing.

questions

Can a Next.js Server Action stream Claude tokens directly to the browser? Not in the traditional SSE sense — Server Actions resolve to a single value. You either keep the SSE connection in a Route Handler, or use async generators with Suspense to simulate progressive rendering from within an action.

Which approach should I use for a production chat UI? A Route Handler with fetch and manual stream reading on the client is more predictable, easier to abort, and simpler to debug than async-generator Server Actions. Use it unless your app is already fully committed to a Server-Action-only data flow.

Does streaming work the same way with SubToAPI as with the raw Claude API? Yes — SubToAPI exposes the same stream: true parameter and SSE event shapes at /v1/messages, so existing streaming client code works against it with just an endpoint and auth header change.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →