Claude API Streaming with React Frontend: A Guide
Claude API streaming with a React frontend
If you're building a chat-style UI and want Claude's responses to appear word-by-word instead of popping in all at once after a 10-second wait, you need streaming. The short answer: you open a server-sent event (SSE) stream from your backend to Claude, forward those chunks to your React app over fetch with a ReadableStream, and update component state as each token arrives. This guide covers the full setup — backend proxy, frontend consumer, and the gotchas that trip people up.
The reason this requires a backend in between (rather than calling the API directly from the browser) is simple: your Claude or SubToAPI key would be exposed in client-side JavaScript if you called the API from React directly. Every production setup proxies the request through your own server, which also gives you a place to do auth, rate limiting, and logging.
Why streaming matters here
Non-streaming responses block until the full completion is generated. For short answers that's fine. For anything longer — code generation, explanations, multi-paragraph content — users stare at a spinner for several seconds. Streaming sends tokens as they're generated, so the UI feels responsive immediately, even if total generation time is unchanged. This is the single biggest perceived-performance win you can make in an LLM-backed product.
Architecture
React app → your backend endpoint → Claude API (via SubToAPI)
↑ |
└──────────── SSE chunks streamed back ──────┘
Your backend does two jobs: it holds the real API key, and it relays the stream without buffering the whole response first.
Backend: proxying a streaming request
Using SubToAPI's streaming endpoint from a Node/Express server:
// server.js
app.post('/api/chat', async (req, res) => {
const { messages } = req.body;
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-opus-4',
max_tokens: 1024,
stream: true,
messages,
}),
});
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value));
}
res.end();
});
This just relays bytes through — no buffering, no waiting for the full response. SubToAPI's streaming format follows the same SSE event structure documented at /docs/streaming, so if you've worked with the standard Claude messages API before, nothing here is unfamiliar — see /docs/messages for the base request shape.
Frontend: consuming the stream in React
On the client, use fetch with a ReadableStream reader instead of EventSource, because EventSource only supports GET requests and you need to POST the conversation history.
import { useState } from 'react';
function Chat() {
const [reply, setReply] = useState('');
const [loading, setLoading] = useState(false);
async function sendMessage(text) {
setReply('');
setLoading(true);
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ role: 'user', content: text }],
}),
});
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\n');
buffer = lines.pop(); // keep incomplete chunk for next read
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.replace('data: ', '').trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.type === 'content_block_delta') {
setReply((prev) => prev + parsed.delta.text);
}
} catch (_) {
// ignore partial/non-JSON lines
}
}
}
setLoading(false);
}
return (
<div>
<p>{reply}</p>
{loading && <span>streaming…</span>}
</div>
);
}
Key details that matter:
- Buffer incomplete chunks. SSE events are separated by
\n\n, but network chunks don't align with event boundaries. Always keep the trailing partial line and prepend it to the next read. - Use
decoder.decode(value, { stream: true }). Without thestream: trueflag, multi-byte UTF-8 characters split across chunks get mangled — this matters a lot with code blocks and non-English text. - Update state incrementally, not by replacing the whole string each time — append deltas, don't re-set the full buffer.
Handling tool use in a streaming context
If your app calls tools mid-conversation, streaming gets slightly more involved: you'll receive content_block_start events for tool calls, incremental input_json_delta events as arguments stream in, and a content_block_stop when the call is complete. Your React state needs to track which content block is active (text vs. tool call) rather than assuming everything is plain text. Details and examples are in /docs/tools.
Common mistakes
- Buffering the whole response server-side before sending to the client. This defeats the purpose — you'll wait the full generation time and then "stream" instantly, which looks broken, not fast.
- Forgetting to close the reader/response on unmount. If the user navigates away mid-stream, abort the fetch with an
AbortControllerto avoid orphaned connections. - Parsing JSON without a try/catch. Partial chunks will occasionally fail to parse mid-stream; swallow those and wait for the next read.
If you're prototyping this and don't want to stand up your own Claude account and billing, SubToAPI turns your existing Claude access into an HTTPS API with streaming, tool use, and usage metadata already wired up — plans start at €9/month on Solo, with a free trial at /signup. The quickstart at /docs/quickstart has you streaming your first response in a few minutes.
questions
Does React need any special library to consume SSE streams? No. fetch with response.body.getReader() and TextDecoder is enough. EventSource is simpler but only supports GET requests, which doesn't work for sending conversation history in a POST body.
Why does my stream show garbled text with code blocks? Almost always a UTF-8 decoding issue — use decoder.decode(value, { stream: true }) instead of decoding each chunk independently, since multi-byte characters can split across network reads.
Can I stream directly from React to Claude without a backend? Not safely. Your API key would be exposed in the browser. Always proxy through a server you control, even if that server does nothing but relay bytes.