The @outkit-dev/react package provides a drop-in React component and streaming hook for rendering Outkit-enhanced AI output. Components render inside Shadow DOM for complete style isolation from your app’s CSS.
Install
The recommended way to install is through the CLI — it detects your framework, picks a
profile, installs the SDK, and writes the backend proxy in one step:
npm install -g @outkit-dev/cli@latest
outkit login
outkit init
See Project Setup for what outkit init writes.
To install the package manually:
npm install @outkit-dev/react
Requires React 18 or 19. Works with Next.js, Vite, Remix, and any React framework.
Architecture
Your API key must stay on your server — never expose it in client-side code:
Your backend calls POST /render/enhance with the API key
Your backend proxies the SSE stream to your frontend
Your frontend feeds the response to the SDK — one line of code
┌──────────┐ POST /render/enhance ┌──────────┐ proxy SSE ┌──────────┐
│ Outkit │ ◄──────────────────────── │ Your │ ──────────────► │ Your │
│ API │ ─────────────────────────► │ Server │ │ Browser │
└──────────┘ SSE stream └──────────┘ └──────────┘
await feedResponse(res)
│
▼
┌──────────────────┐
│ <AIRenderer> │
│ Shadow DOM │
└──────────────────┘
Quick Start
import { AIRenderer , useBlockStream } from '@outkit-dev/react' ;
function ChatMessage ({ messageId } : { messageId : string }) {
const { blocks , design , isStreaming , feedResponse , reset } = useBlockStream ();
const enhance = async () => {
reset ();
try {
const response = await fetch ( `/api/enhance/ ${ messageId } ` );
await feedResponse ( response );
} catch ( err ) {
console . error ( 'Enhance failed:' , err );
}
};
return (
< div >
< button onClick = { enhance } > Enhance </ button >
< AIRenderer blocks = { blocks } design = { design } streaming = { isStreaming } theme = "auto" />
</ div >
);
}
That’s it. feedResponse handles SSE parsing, design token extraction, streaming JSON parsing, and completion — all internally. Blocks update at 60fps via requestAnimationFrame batching. It throws on non-ok HTTP responses, so wrap it in try/catch.
Your /api/enhance/:id backend endpoint should proxy the Outkit SSE stream. See Backend Proxy below for examples.
useBlockStream
The streaming hook manages block state, design tokens, and batched React state updates.
const {
blocks , // ContentBlock[] — pass to <AIRenderer blocks={blocks} />
design , // Record<string, string> | undefined — pass to <AIRenderer design={design} />
isStreaming , // boolean — pass to <AIRenderer streaming={isStreaming} />
streamState , // 'idle' | 'streaming' | 'done' | 'error' | 'destroyed'
// Tier 1 — Full automation (recommended)
feedResponse , // (response: Response) => Promise<{ blocks, design }>
// Tier 2 — Raw SSE bytes
feedSSE , // (rawText: string) => void
// Tier 3 — One unwrapped event payload
feedEvent , // (data: string) => void
// Tier 4 — Full manual control
feedChunk , // (data: string) => void
feedMeta , // (tokens: Record<string, string>) => void
feedDone , // () => void
reset , // () => void — clear everything, abort in-flight streams
} = useBlockStream ();
Choosing a Tier
Pick the tier that matches how your backend delivers data to the browser:
Tier Method When to use 1 feedResponse(res)Your backend proxies the Outkit SSE stream as-is. Start here. 2 feedSSE(text)You’re reading the response body yourself and want to forward raw chunks. 3 feedEvent(data)You receive individual events via WebSocket, Socket.IO, or a custom transport. 4 feedChunk / feedMeta / feedDoneYou need full manual control over every event type.
Each tier calls the one below it internally. feedResponse calls feedSSE, which calls feedEvent, which calls feedChunk/feedMeta/feedDone.
Tier 1: feedResponse(response) — Recommended
The simplest integration. Pass a fetch Response and the SDK handles everything:
const enhance = async () => {
reset ();
try {
const response = await fetch ( '/api/enhance/123' );
const { blocks , design } = await feedResponse ( response );
// blocks and design are also available via the hook's reactive state,
// but the return value is useful for post-stream logic:
console . log ( `Rendered ${ blocks . length } blocks` );
} catch ( err ) {
console . error ( 'Stream failed:' , err );
}
};
Reads the SSE stream, parses data: lines, extracts design tokens, accumulates LLM chunks, and signals completion
Returns { blocks, design } — the final parsed blocks and design tokens after the stream completes
Auto-resets if a previous stream is in progress (safe for rapid re-triggers)
Throws on non-ok HTTP responses (catch and show your own error UI)
Abortable via reset() — cancels the in-flight reader
Tier 2: feedSSE(rawText)
For when you’re reading the response body yourself:
const reader = response . body . getReader ();
const decoder = new TextDecoder ();
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
feedSSE ( decoder . decode ( value , { stream: true }));
}
Handles SSE framing (data: prefix stripping, \n\n event boundaries, fragment buffering across TCP chunks).
Tier 3: feedEvent(data)
For WebSocket or custom transports delivering one event at a time:
socket . on ( 'outkit-event' , ( data : string ) => {
feedEvent ( data ); // handles [DONE], meta routing, and LLM chunk delegation
});
Full manual control — you parse SSE yourself and call the right method:
for ( const line of sseLines ) {
if ( ! line . startsWith ( 'data: ' )) continue ;
const data = line . slice ( 6 );
if ( data === '[DONE]' ) {
feedDone ();
return ;
}
try {
const parsed = JSON . parse ( data );
if ( parsed . type === 'meta' && parsed . design ) {
feedMeta ( parsed . design );
continue ;
}
} catch { /* raw LLM token */ }
feedChunk ( data );
}
feedChunk(data) — Appends to an internal JSON buffer, runs the streaming parser, and updates blocks.
feedMeta(tokens) — Sets design tokens. Flushes any blocks that were buffered waiting for design.
feedDone() — Final parse of the accumulated buffer, flush pending blocks, mark stream complete.
reset()
Clears all state: blocks, design tokens, streaming flag, and aborts any in-flight feedResponse. Call before starting a new stream.
<AIRenderer>
Renders ContentBlock[] inside Shadow DOM with full style isolation.
Props
Prop Type Default Description blocksContentBlock[]— Array of content blocks from the stream designRecord<string, string>— CSS custom properties from your design profile streamingbooleanfalseEnables streaming cursor, auto-skeletons, and entry animations theme"light" | "dark" | "auto""auto"Color theme. "auto" detects prefers-color-scheme loadingbooleanfalseShow skeleton placeholder instead of content skeletonType"table" | "card" | "chart" | "text" | "generic""generic"Skeleton variant when loading is true confidenceThresholdnumber0.7Minimum confidence to render a component. Below this, fallback text is shown classNamestring— Class applied to the wrapper <div> onInteraction(event: OutkitInteractionEvent) => void— Callback for interactive events (table sort, checkbox toggle, etc.) specComponentBlock— Render a single component instead of a blocks array placeholderReact.ReactNode— Content shown when no blocks or spec provided
Minimal Usage
< AIRenderer blocks = { blocks } />
Full Usage
< AIRenderer
blocks = { blocks }
design = { design }
streaming = { isStreaming }
theme = "auto"
confidenceThreshold = { 0.7 }
onInteraction = { ( event ) => console . log ( event ) }
/>
Theme Behavior
"auto" — Watches prefers-color-scheme media query and updates dynamically
"light" / "dark" — Forces a specific mode regardless of system preference
Design tokens with -dark suffix (e.g. --outkit-text-dark) are automatically swapped in when the resolved theme is dark.
Design Tokens
Design tokens are CSS custom properties injected into the Shadow DOM. They come from the meta SSE event during streaming or the design field in JSON mode.
When using feedResponse or any higher-tier method, tokens are extracted and applied automatically — you just pass design to <AIRenderer>.
< AIRenderer
blocks = { blocks }
design = { design } // ← from useBlockStream(), applied automatically
/>
You can also pass tokens manually:
< AIRenderer
blocks = { blocks }
design = { {
'--outkit-primary' : '#b30069' ,
'--outkit-bg' : '#fbf9f8' ,
'--outkit-text' : '#1b1c1c' ,
'--outkit-font' : '"Inter", system-ui, sans-serif' ,
} }
/>
Available Tokens
Token Description Default --outkit-fontFont family System font stack --outkit-textPrimary text color #0f172a--outkit-text-mutedSecondary text color #64748b--outkit-bgBackground #ffffff--outkit-bg-subtleSubtle background (headers, code) #f8fafc--outkit-bg-hoverHover state background #f1f5f9--outkit-borderBorder color #e2e8f0--outkit-radiusBorder radius 8px--outkit-primaryPrimary accent (links, highlights) #3b82f6--outkit-successSuccess state #16a34a--outkit-warningWarning state #d97706--outkit-errorError state #dc2626
Backend Proxy
Your backend proxies the Outkit API so your API key never reaches the browser.
Express / Node.js
FastAPI
Next.js Route Handler
import express from 'express' ;
const app = express ();
app . post ( '/api/enhance/:messageId' , async ( req , res ) => {
const aiText = await getMessageText ( req . params . messageId );
const outkitResponse = await fetch ( 'https://api.outkit.dev/render/enhance' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-outkit-api-key' : process . env . OUTKIT_API_KEY ! ,
},
body: JSON . stringify ({
content: aiText ,
context: 'User chat message' ,
}),
});
if ( ! outkitResponse . ok || ! outkitResponse . body ) {
return res . status ( outkitResponse . status ). end ();
}
// Pipe the SSE stream to the client
res . setHeader ( 'Content-Type' , 'text/event-stream' );
res . setHeader ( 'Cache-Control' , 'no-cache' );
const reader = outkitResponse . body . getReader ();
try {
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
res . write ( value );
}
} finally {
res . end ();
}
});
SSE Event Types
When streaming from /render/enhance, the API sends these SSE events:
Event Format Description Design meta {"type":"meta","design":{...}}Design tokens for your profile. Always first. Picker meta {"type":"meta","picker":{...}}Internal classification metadata. Skip this. LLM chunks Raw JSON tokens Partial JSON structure streamed token-by-token Done [DONE]Stream complete signal
When using feedResponse (Tier 1) or feedSSE (Tier 2), you don’t need to handle these events manually — the SDK parses them for you.
Framework-Agnostic Core
The streaming protocol handler is available as a standalone package for non-React integrations:
npm install @outkit-dev/core
See the @outkit-dev/core reference for using OutkitStream with Vue, Svelte, vanilla JS, or Node.js.
Streaming Utilities
For advanced integrations, the low-level parsing functions are also exported:
import {
parseStreamingBlocks ,
completePartialJson ,
expandWireBlock ,
} from '@outkit-dev/react' ;
parseStreamingBlocks(accumulated: string): ContentBlock[]
Takes the full accumulated raw LLM output, closes unclosed JSON structures, parses, expands wire format, and returns all currently renderable blocks.
completePartialJson(partial: string): string
Closes unclosed strings, arrays, and objects so partial JSON can be parsed. Used internally by parseStreamingBlocks.
expandWireBlock(block: Record<string, unknown>): Record<string, unknown>
Expands compact wire format ({ c, v, p }) to full format ({ type, component, version, props }). The renderer does this at render time automatically.