← Blog

How to Stream Claude Responses to a React Frontend

2026-11-04 · 5 min read · SubToAPI Team

Streaming Claude responses into a React app means showing text as it's generated, token by token, instead of waiting for the full response and dumping it on screen. This is what gives chat interfaces that familiar "typing" feel, and it's the right default for any UI where response latency matters — which is basically all of them.

The short version: Claude's API supports stream: true, which returns a Server-Sent Events (SSE) stream of JSON events. You can't call that stream directly from the browser with your API key (that would expose it to every user), so you need a small backend route that proxies the stream, and a React component that reads the response body incrementally using the Fetch API's ReadableStream. Below is the full setup, from backend proxy to rendered UI.

Why you can't stream directly from the browser

Two problems if you try to call Claude (or any LLM provider) straight from client-side JavaScript:

The fix is a thin backend endpoint that holds the real key, calls the LLM with streaming enabled, and re-streams the chunks to the browser over your own domain. This is also where you'd plug in auth, rate limiting, and usage tracking per user.

Step 1: Set up a streaming backend route

If you're using SubToAPI to turn your Claude access into an HTTPS API, the request looks like this:

curl https://api.subtoapi.app/v1/messages \
  -H "Authorization: Bearer $SUBTOAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-sonnet-4.5",
    "stream": true,
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Write a short product tagline."}]
  }'

With stream: true, the response is SSE: a sequence of event: / data: lines, with text arriving in content_block_delta events as it's generated. A minimal Node/Express proxy just forwards those bytes to the browser:

app.post("/api/chat/stream", async (req, res) => {
  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.5",
      stream: true,
      max_tokens: 1024,
      messages: req.body.messages,
    }),
  });

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  for await (const chunk of upstream.body) {
    res.write(chunk);
  }
  res.end();
});

This is the whole backend piece: your server authenticates with SubToAPI, your frontend authenticates with your server (session cookie, JWT, whatever you already use). The Claude key never reaches the browser. See /docs/streaming and /docs/messages for the full event payload shape.

Step 2: Consume the SSE stream in React

EventSource only supports GET requests without custom headers, which doesn't fit a chat POST body well. Use fetch with a ReadableStream reader instead — it works for POST and gives you full control over parsing.

import { useState, useCallback } from "react";

function useClaudeStream() {
  const [output, setOutput] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);

  const send = useCallback(async (messages) => {
    setOutput("");
    setIsStreaming(true);

    const response = await fetch("/api/chat/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages }),
    });

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

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop(); // keep incomplete line for next chunk

      for (const line of lines) {
        if (!line.startsWith("data:")) continue;
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") continue;

        try {
          const event = JSON.parse(payload);
          if (event.type === "content_block_delta") {
            setOutput((prev) => prev + event.delta.text);
          }
        } catch {
          // ignore partial/non-JSON lines
        }
      }
    }

    setIsStreaming(false);
  }, []);

  return { output, isStreaming, send };
}

Step 3: Render it

function Chat() {
  const { output, isStreaming, send } = useClaudeStream();

  return (
    <div>
      <button
        onClick={() => send([{ role: "user", content: "Explain SSE in two sentences." }])}
        disabled={isStreaming}
      >
        {isStreaming ? "Streaming…" : "Ask Claude"}
      </button>
      <p>{output}</p>
    </div>
  );
}

Each content_block_delta appends a fragment of text to output, triggering a re-render. Because React batches state updates, you get smooth incremental rendering without manual throttling in most cases. If you're streaming to a long scrolling area, auto-scroll to the bottom on each update (ref.current.scrollTop = ref.current.scrollHeight).

Handling tool use and stop events

If your requests use tool calls (see /docs/tools), the stream includes content_block_start events with type: "tool_use" before the arguments stream in as deltas. Check event.type in the parsing loop and branch accordingly — render a "thinking" indicator for tool blocks, and only append raw text for text content blocks. Also watch for message_stop or message_delta with a stop_reason so you can flip isStreaming back to false even if the connection closes unexpectedly.

A note on reliability

Streaming connections can drop mid-response on flaky networks. Wrap the fetch call in a try/catch, and on failure, show a "resume" or "retry" action rather than silently truncating the output — users notice half-finished sentences. If you're prototyping this quickly, /docs/quickstart walks through a working streaming example end to end, and the free trial at /signup is enough to test the full flow without committing to a plan.

How is this different from streaming OpenAI responses to React?

Mechanically almost identical — both use SSE with chunked JSON events. The main difference is the event type names (content_block_delta vs delta.content) and how tool calls are represented in the stream, so your parsing logic needs small adjustments per provider.

Can I use EventSource instead of fetch for this?

Only if you can trigger the stream with a GET request and no custom auth headers, since EventSource doesn't support POST bodies or arbitrary headers. For authenticated chat requests with a message payload, fetch with a ReadableStream reader is the more flexible choice.

Do I need a backend proxy, or can I stream straight from a serverless function to the browser?

You still need something between the browser and Claude — it can absolutely be a serverless function rather than a long-running server, as long as the platform supports streaming responses (not all do by default). The security requirement is the same either way: the real API key stays server-side.

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 →