The Vercel AI SDK gives you two primitives for streaming from React Server Components to the client: createStreamableValue for serializable data and createStreamableUI for live React components. They solve adjacent but distinct problems, and picking the wrong one forces awkward workarounds later. This comparison breaks down where each fits, what the type system buys you, and how they behave under real streaming constraints.
What each primitive actually does
createStreamableValue creates a server-side writable stream that emits JSON-serializable values. The client reads a promise that resolves to the final value, with updates available via a subscribe callback or by awaiting the promise directly. It’s essentially a typed pipe for data — objects, arrays, primitives — that crosses the RSC boundary without hydration overhead.
// app/actions.ts
import { createStreamableValue } from 'ai/rsc';
export async function generateReport(params: Params) {
const stream = createStreamableValue<ReportSection[]>([]);
(async () => {
for await (const section of generateSections(params)) {
stream.update(current => [...current, section]);
}
stream.done();
})();
return { report: stream.value };
}
createStreamableUI creates a stream that emits React nodes — actual JSX elements — which the client renders as they arrive. The server writes components, not data, and the client mounts them incrementally. This is generative UI in the literal sense: the server decides what component to show, with what props, and the client renders it without a full-page hydration pass.
// app/actions.ts
import { createStreamableUI } from 'ai/rsc';
import { Chart, Table, Summary } from '@/components/report';
export async function generateDashboard(params: Params) {
const stream = createStreamableUI<React.ReactNode>();
(async () => {
stream.update(<Summary data={await fetchSummary(params)} />);
stream.update(<Chart data={await fetchChartData(params)} />);
stream.update(<Table data={await fetchTableData(params)} />);
stream.done();
})();
return { dashboard: stream.value };
}
Serialization boundary and what crosses it
The fundamental difference is the serialization boundary. createStreamableValue uses the Next.js RSC payload format — the same mechanism that sends props from server components to client components. Anything you pass must be serializable: plain objects, arrays, dates (as strings), no functions, no class instances, no React elements. The client receives a plain JavaScript value.
createStreamableUI bypasses serialization for the component tree itself. The server sends a stream of RSC payload instructions that tell the client “mount this component with these props.” The component code must already exist on the client (imported in a 'use client' boundary). Only the props cross the wire, and they still obey RSC serialization rules. But the component identity — which component, in what order — is driven by the server at stream time.
This means createStreamableUI requires a client-side component registry. You cannot stream arbitrary components from a dynamic import path unless that component is already in the client bundle. createStreamableValue has no such constraint — the client just receives data and decides locally how to render it.
Type safety and inference
Both APIs are generic, but the type flow differs. With createStreamableValue<T>, T is the shape of the accumulated value. The update callback receives the current accumulated value and returns the next. TypeScript infers the stream’s value property as Promise<T>, and subscribe gives you T on each emission.
const stream = createStreamableValue<Message[]>([]);
// stream.value: Promise<Message[]>
// stream.subscribe((messages: Message[]) => { ... })
With createStreamableUI<T>, T constrains what each update call accepts — typically React.ReactNode or a discriminated union of component prop types. The stream’s value is Promise<T>, but the client receives a special StreamableUI object that renders as a React element. You don’t subscribe to typed updates; you render the stream directly.
const stream = createStreamableUI<React.ReactNode>();
// stream.value: Promise<React.ReactNode>
// Client: <Suspense fallback={<Skeleton />}>{stream.value}</Suspense>
The createStreamableValue path gives you richer client-side type awareness of intermediate states. The createStreamableUI path pushes type complexity into your component prop definitions — which is often where you want it anyway.
Client consumption patterns
createStreamableValue integrates with standard React patterns. You await the promise in a client component, or use subscribe for incremental updates without Suspense.
// components/ReportViewer.tsx
'use client';
import { use } from 'react';
export function ReportViewer({ stream }: { stream: StreamableValue<ReportSection[]> }) {
const sections = use(stream.value); // Suspense-ready
return sections.map(s => <SectionCard key={s.id} {...s} />);
}
// Or incremental without Suspense:
export function LiveReportViewer({ stream }) {
const [sections, setSections] = useState<ReportSection[]>([]);
useEffect(() => stream.subscribe(setSections), [stream]);
return sections.map(s => <SectionCard key={s.id} {...s} />);
}
createStreamableUI is designed for direct rendering. The stream value is itself a React node that emits children as they arrive. You wrap it in Suspense and it “just works.”
// components/Dashboard.tsx
'use client';
import { use } from 'react';
import { StreamableUI } from 'ai/rsc';
export function Dashboard({ stream }: { stream: StreamableUI<React.ReactNode> }) {
return (
<Suspense fallback={<DashboardSkeleton />}>
{use(stream.value)}
</Suspense>
);
}
The StreamableUI type implements React.ReactNode via a custom $$typeof symbol, so it slots into JSX naturally. But you lose fine-grained control over intermediate renders — the stream decides when children appear.
Error handling and cancellation
Both streams support stream.error(error) to terminate with an error, which rejects the client promise. But the recovery paths differ.
With createStreamableValue, the client catches a rejected promise. You can wrap the use(stream.value) in an error boundary, or handle it in the subscribe callback. The accumulated value up to the error is still available if you caught it via subscription.
stream.subscribe({
next: setSections,
error: err => { /* partial data still in state */ }
});
With createStreamableUI, an error terminates the stream and the rendered output throws to the nearest error boundary. There’s no partial render recovery — the component tree up to that point is already mounted, but the stream stops. If you need graceful degradation (show what arrived, mark the rest failed), you must model that in the components themselves: stream a <ErrorState /> component instead of calling stream.error().
Cancellation is implicit: if the client unmounts before stream.done(), the server-side async generator should respect AbortSignal from the request context. Neither API automatically cancels the server work — you wire that yourself via request.signal.
Performance and bundle impact
createStreamableValue adds zero client-side bundle weight beyond the AI SDK’s small runtime. The client receives JSON and renders with your existing components. The server does the work of deciding what data to send.
createStreamableUI requires the client to have all possible streamed components in its bundle. If your dashboard can stream 20 different widget types, all 20 must be imported in the client entry point (or dynamically imported with known paths). This can bloat the client bundle if you’re not careful with code splitting.
On the server, createStreamableUI avoids serializing large data payloads — you send component references and small prop objects instead of full HTML or data trees. But the RSC payload for component instructions has its own overhead. For data-heavy streams (thousands of rows), createStreamableValue with client-side virtualization often wins. For component-heavy streams (heterogeneous UI), createStreamableUI avoids a round-trip where the client would map data to components anyway.
Streaming choreography: interleaving and dependencies
createStreamableValue gives you a single accumulating value. If you need to stream multiple independent data streams, you create multiple createStreamableValue calls and return them in an object. The client receives all promises in parallel.
return {
users: userStream.value,
posts: postStream.value,
notifications: notificationStream.value,
};
createStreamableUI streams a single React node tree. If you want parallel independent UI regions, you stream a fragment with multiple children — but they render in sequence as the server emits them. True parallel streaming of independent UI trees requires multiple createStreamableUI calls returned as an object, each with its own Suspense boundary on the client.
// Server
const left = createStreamableUI<React.ReactNode>();
const right = createStreamableUI<React.ReactNode>();
// Client
<div className="grid">
<Suspense fallback={<Skeleton />}>{use(left.value)}</Suspense>
<Suspense fallback={<Skeleton />}>{use(right.value)}</Suspense>
</div>
This is a meaningful ergonomic difference: createStreamableValue scales to N independent streams naturally; createStreamableUI requires N Suspense boundaries for N independent streams.
Ecosystem and framework integration
Both APIs live in ai/rsc and work with Next.js App Router (RSC) and any React 19+ RSC-compatible framework. They integrate with the AI SDK’s streamText and streamObject for LLM-driven generation.
createStreamableValue pairs naturally with streamObject — you accumulate structured output and stream the partial object.
const stream = createStreamableValue<Report>(initialReport);
const { partialObjectStream } = streamObject({ schema: reportSchema, prompt });
for await (const partial of partialObjectStream) {
stream.update(partial);
}
createStreamableUI pairs with tool-calling loops where each tool result maps to a component. The model calls renderChart, renderTable, etc., and the server streams the corresponding component.
const { textStream, toolCalls } = streamText({ tools: { renderChart, renderTable } });
for await (const call of toolCalls) {
if (call.name === 'renderChart') stream.update(<Chart data={call.args} />);
}
The AI SDK’s createDataStreamResponse (for non-RSC routes) is a separate API — don’t confuse it with these RSC-specific primitives.
Comparison table
| Dimension | createStreamableValue | createStreamableUI |
|---|---|---|
| Payload type | Serializable JSON (objects, arrays, primitives) | React nodes (component references + props) |
| Client requirement | Data only — client decides rendering | Components must exist in client bundle |
| Type safety | Full inference on accumulated value T |
Constrained by React.ReactNode or prop union |
| Consumption | use(stream.value) or stream.subscribe(cb) |
Direct render: {use(stream.value)} in Suspense |
| Intermediate updates | Typed callbacks with full current state | Implicit via mounted component tree |
| Error recovery | Partial data accessible via subscription | Throws to error boundary; no partial API |
| Parallel streams | Natural — return multiple values in object | Requires multiple streams + multiple Suspense boundaries |
| Bundle impact | Minimal — only your rendering components | All streamable components in client bundle |
| Best for | Data streaming, progressive disclosure, client-side transformation | Generative UI, server-driven component composition, heterogeneous widgets |
| RSC payload | Standard data serialization | Component instruction stream |
Which to choose
Use createStreamableValue when:
- The server produces data that the client renders with known components. This covers most “streaming list” patterns: chat messages, search results, log lines, progress updates.
- You need client-side transformation, filtering, or virtualization of the streamed data. The client owns the render logic.
- You want fine-grained control over intermediate states (loading skeletons per item, optimistic updates, local sorting).
- The component set is fixed and small, or you prefer colocation of render logic on the client.
- You’re streaming to non-React consumers (though the RSC payload is React-specific, the data shape is portable).
Use createStreamableUI when:
- The server decides which components to render, not just their props. Think: an LLM that chooses between
<Chart />,<Table />,<Map />based on the query. - You have a heterogeneous widget system where the component registry is stable but the composition is dynamic. Dashboards, report builders, notebook interfaces.
- You want zero client-side mapping logic — the server emits the final UI tree directly.
- You can accept the bundle cost of including all possible streamed components.
- You’re building a true generative UI system where the model drives component selection via tool calls.
Hybrid approach (common in practice):
Stream data with createStreamableValue for the bulk content, and a small createStreamableUI for dynamic “insert component here” slots. For example, a report stream delivers sections as data, but each section can optionally include a server-chosen interactive widget streamed via UI.
// Server
const reportStream = createStreamableValue<ReportSection[]>([]);
const widgetStream = createStreamableUI<React.ReactNode>();
// Client renders report sections, with a slot for the widget
<ReportSections sections={use(reportStream.value)} widgetSlot={use(widgetStream.value)} />
This keeps the heavy data path efficient while preserving server-driven component insertion where it matters.
The rule of thumb: if you find yourself writing a giant switch on the client to map streamed type fields to components, you wanted createStreamableUI. If you find yourself stuffing component props into JSON and wishing for type safety, you wanted createStreamableValue. Both are legitimate patterns — the AI SDK gives you the right primitive for each, so use the one that matches your architecture.