Consuming Dify Workflow SSE Streams in React 19 with Vercel AI SDK
Self-hosting agentic platforms like langgenius/dify (recently shipping v1.17.x with hardened workflow streaming) solves internal orchestration, but hooking its streaming API directly into modern React frontends introduce
Self-hosting agentic platforms like langgenius/dify (recently shipping v1.17.x with hardened workflow streaming) solves internal orchestration, but hooking its streaming API directly into modern React frontends introduces sharp edge cases. Dify does not output standard OpenAI raw delta chunks by default; its workflow execution endpoints stream structured JSON events (workflow_started, node_started, text_chunk, workflow_finished).
If you pipe Dify's Server-Sent Events (SSE) directly into @ai-sdk/react or React 19 state setters without stream transformation, the UI will choke or fail on event parsing. Here is how to build an unbuffered bridge.
The Problem: Reverse Proxy Buffering & Custom SSE Framing
Two issues hit production teams early:
- Reverse-Proxy Buffering: Nginx or Envoy in front of Dify often buffers chunked HTTP responses until 4KB–8KB chunks accumulate, destroying the perceived typewriter effect.
-
Protocol Mismatch: Vercel AI SDK's
useChatanduseCompletionexpect OpenAI-compatible SSE chunks (data: {"choices":[...]}) or the AI SDK UI protocol format, not Dify workflow events.
Solution: Edge Route Adapter with TransformStream
Below is a Next.js App Router route (app/api/dify-stream/route.ts) that transforms Dify's workflow event stream into an OpenAI-compatible delta format consumed seamlessly by useCompletion or useChat.
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
const { query, inputs = {} } = await req.json();
const difyRes = await fetch(`${process.env.DIFY_API_URL}/v1/workflows/run`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DIFY_API_KEY}`,
'Content-Type': 'application/json',
// Disables reverse proxy response buffering upstream
'X-Accel-Buffering': 'no',
},
body: JSON.stringify({
inputs,
response_mode: 'streaming',
user: 'web-client',
}),
});
if (!difyRes.ok || !difyRes.body) {
return new Response('Dify upstream failure', { status: 502 });
}
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = textDecoder.decode(chunk, { stream: true });
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const rawJson = line.slice(6).trim();
if (!rawJson) continue;
try {
const payload = JSON.parse(rawJson);
// Dify workflow text output event
if (payload.event === 'text_chunk') {
const delta = payload.data?.text || '';
controller.enqueue(textEncoder.encode(`0:${JSON.stringify(delta)}\n`));
} else if (payload.event === 'workflow_finished') {
controller.enqueue(textEncoder.encode(`d:{"finishReason":"stop"}\n`));
}
} catch {
// Skip heartbeat / keepalive fragments
}
}
},
});
return new Response(difyRes.body.pipeThrough(transformStream), {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Vercel-AI-Data-Stream': 'v1',
'Cache-Control': 'no-cache, no-transform',
},
});
}
Client-Side: React 19 Hook Integration
In React 19 client components, consumption remains clean via standard hooks:
'use client';
import { useCompletion } from '@ai-sdk/react';
export function DifyWorkflowViewer() {
const { completion, input, handleInputChange, handleSubmit, isLoading, stop } = useCompletion({
api: '/api/dify-stream',
});
return (
<div className="p-4 max-w-xl mx-auto space-y-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Trigger workflow..."
className="border p-2 rounded flex-1"
/>
<button type="submit" disabled={isLoading} className="px-4 py-2 bg-blue-600 text-white rounded">
Run
</button>
{isLoading && (
<button type="button" onClick={stop} className="px-3 py-2 bg-red-600 text-white rounded">
Abort
</button>
)}
</form>
<div className="p-4 bg-gray-900 text-gray-100 rounded min-h-[160px] whitespace-pre-wrap font-mono text-sm">
{completion || (isLoading ? 'Receiving first chunk...' : 'Awaiting input.')}
</div>
</div>
);
}
This architecture decouples Dify's backend orchestration engine from your design system, eliminates UI stalls during heavy token generation, and ensures client-side AbortController cancellation works cleanly on network cuts.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.