Wiring up react sse llm streaming gpt-4o claude into a production chat UI means reconciling two incompatible streaming response shapes behind a single browser connection. OpenAI emits choices[0].delta.content chunks; Anthropic emits delta.text. This guide builds a small Node proxy that normalizes both into one Server-Sent Events contract, then a React hook that consumes it with fetch and renders tokens incrementally.
Step 1: Stand up a streaming proxy endpoint
You cannot call GPT-4o or Claude directly from the browser with streaming and keep keys secret. Put a thin Node server in front. Use Express or Fastify. The endpoint accepts a POST with { model, messages } and returns text/event-stream.
We’ll use the official SDKs to avoid hand-rolling HTTP. Install:
npm install express openai @anthropic-ai/sdk
Server skeleton:
import express from 'express';
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
const app = express();
app.use(express.json());
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
app.post('/api/stream', async (req, res) => {
const { model, messages } = req.body;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// dispatch based on model prefix
if (model.startsWith('gpt')) {
// see Step 2
} else if (model.startsWith('claude')) {
// see Step 2
} else {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'unknown model' })}\n\n`);
res.end();
}
});
app.listen(3001, () => console.log('proxy on :3001'));
Keep the proxy stateless. Pass through messages after validating shape. The react sse llm streaming gpt-4o claude approach simplifies once the client ignores provider specifics.
Step 2: Normalize provider chunks to a single SSE event
OpenAI’s streaming SDK yields chat.completion.chunk objects. Anthropic’s SDK yields text events. We write a helper that pushes data: {"token": "..."} lines to the response.
function sendToken(res: express.Response, token: string) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
// Inside the gpt branch:
const stream = await openai.chat.completions.create({
model,
messages,
stream: true,
});
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content;
if (token) sendToken(res, token);
}
res.write('event: done\ndata: {}\n\n');
res.end();
// Inside the claude branch:
const claudeStream = await anthropic.messages.create({
model,
max_tokens: 1024,
messages: messages.map(m => ({ role: m.role, content: m.content })),
stream: true,
});
for await (const event of claudeStream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
sendToken(res, event.delta.text);
}
}
res.write('event: done\ndata: {}\n\n');
res.end();
The client now only needs to parse one format. If you want to skip maintaining two SDKs, an OpenAI-compatible gateway such as n4n.ai addresses 240+ models behind one endpoint and forwards provider cache-control hints, so the same SSE client works for gpt-4o and claude without branching.
Step 3: Build a fetch-based React streaming hook
EventSource only does GET. Our proxy needs POST to receive messages. Use fetch and read the response body as a stream.
import { useState, useRef, useCallback } from 'react';
export function useChatStream() {
const [tokens, setTokens] = useState<string[]>([]);
const [done, setDone] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const send = useCallback(async (model: string, messages: {role:string; content:string}[]) => {
setTokens([]);
setDone(false);
const ctrl = new AbortController();
abortRef.current = ctrl;
const res = await fetch('/api/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages }),
signal: ctrl.signal,
});
if (!res.body) throw new Error('no body');
const reader = res.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 events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const evt of events) {
const line = evt.split('\n').find(l => l.startsWith('data:'));
if (!line) continue;
const json = JSON.parse(line.slice(5).trim());
if (json.token) setTokens(prev => [...prev, json.token]);
}
}
setDone(true);
}, []);
const cancel = useCallback(() => abortRef.current?.abort(), []);
return { tokens, done, send, cancel };
}
This hook accumulates tokens in state. For high-throughput streams, batch updates with requestAnimationFrame to avoid re-render storms, but for most chat UIs the simple append is fine.
Step 4: Render the streamed response
A component consumes the hook. Show tokens joined, with a blinking cursor until done.
function ChatBox({ model, messages }: { model: string; messages: any[] }) {
const { tokens, done, send, cancel } = useChatStream();
return (
<div>
<div className="messages">
{messages.map((m, i) => <p key={i}><b>{m.role}:</b> {m.content}</p>)}
<p><b>assistant:</b> {tokens.join('')}{!done && '▍'}</p>
</div>
<button onClick={() => send(model, messages)}>Send</button>
<button onClick={cancel}>Stop</button>
</div>
);
}
Keep the assistant message in a separate state if you want to persist it after streaming. The hook above resets tokens each call; lift that state up if needed.
Step 5: Handle errors and cleanup
Network failures and provider rate limits surface as aborts or thrown exceptions. Wrap send in try/catch and surface a status line.
try {
await send(model, messages);
} catch (err) {
if ((err as any).name !== 'AbortError') {
setError('Stream failed');
}
}
On unmount, abort the controller to avoid setting state on a dead component:
useEffect(() => () => abortRef.current?.abort(), []);
Also set a server-side timeout. If the provider hangs, close the response after 30s.
Step 6: Verify the pipeline end to end
Start the proxy and the React dev server. Use curl to confirm SSE shape before touching the UI:
curl -N -X POST http://localhost:3001/api/stream \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}]}'
You should see lines like data: {"token":"Hello"} followed by event: done. Repeat with "model":"claude-3-5-sonnet" (or current Claude model id) to confirm the Anthropic branch.
In the browser, open the network tab, trigger Send, and watch the Event Stream payload fill in. Testing react sse llm streaming gpt-4o claude requires both model branches to produce identical client-side events. If tokens appear with no full-page reload and the cursor stops at done, the integration works.
Caveats when shipping
GPT-4o and Claude have different token limits and system prompt handling. Normalize roles: Anthropic rejects system in messages array; pass it as system parameter. OpenAI accepts it inline. Do that mapping in the proxy before calling SDKs.
For production, add request validation (zod), per-IP rate limiting, and don’t expose raw provider errors. The react sse llm streaming gpt-4o claude pattern is stable once the proxy owns provider differences.
If you need automatic fallback when a provider is degraded, a gateway that honors client routing directives can swap models without frontend changes—but the SSE contract above stays identical.
That’s the whole flow. Build the proxy, normalize, consume with fetch, render, verify.