Appearance
Recipe: OpenAI + Generative DOM
Stream OpenAI Chat Completions into a live DOM without flicker or scroll jumps.
Why this recipe
OpenAI's streaming API delivers content as a sequence of ChatCompletionChunk objects, each containing a small delta.content string. The naive approach — accumulating all deltas into one string and re-rendering the whole thing on every chunk — re-parses everything on each iteration, flickers visibly, and fights with overflow: auto containers. Generative DOM streams each delta into a running parser and only patches the new DOM nodes, so scroll position and cursor focus survive.
What you need
- Node 18+ or a modern browser with a server proxy (never put your API key in the browser)
@generative-dom/coreplus the markdown plugins you want- Optional:
@generative-dom/react - The official
openaiSDK v4+
sh
pnpm add openai @generative-dom/core @generative-dom/plugin-markdown-base \
@generative-dom/plugin-markdown-inline @generative-dom/plugin-markdown-heading \
@generative-dom/plugin-markdown-code @generative-dom/plugin-markdown-listVanilla example
ts
import OpenAI from 'openai';
import { GenerativeDom } from '@generative-dom/core';
import { markdownBase } from '@generative-dom/plugin-markdown-base';
import { markdownInline } from '@generative-dom/plugin-markdown-inline';
import { markdownHeading } from '@generative-dom/plugin-markdown-heading';
import { markdownCode } from '@generative-dom/plugin-markdown-code';
import { markdownList } from '@generative-dom/plugin-markdown-list';
const client = new OpenAI(); // reads OPENAI_API_KEY from env
const container = document.getElementById('chat')!;
const md = new GenerativeDom({
container,
plugins: [
markdownBase(),
markdownInline(),
markdownHeading(),
markdownCode(),
markdownList(),
],
});
export async function streamAnswer(prompt: string): Promise<void> {
md.reset(); // wipe the previous answer; keep plugins registered
const stream = await client.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: prompt }],
// see official SDK docs for full options
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) md.push(delta);
}
md.flush();
}Three things worth noticing:
md.reset()before the new stream. Otherwise the new answer is appended to the old one. If your chat UI renders each message in a separate Generative DOM instance, you can skip this.- Guard on
delta. The first and last chunks often carry role info or finish reasons with nocontent. Don't pushundefined. md.flush()at the end. Generative DOM debounces renders againstrequestAnimationFrame. Withoutflush(), the last few tokens may sit in the buffer for up todebounceMsafter the stream closes.
React example
tsx
import { useCallback, useMemo, useRef } from 'react';
import OpenAI from 'openai';
import { useGenerativeDom } from '@generative-dom/react';
import { markdownBase } from '@generative-dom/plugin-markdown-base';
import { markdownInline } from '@generative-dom/plugin-markdown-inline';
import { markdownHeading } from '@generative-dom/plugin-markdown-heading';
import { markdownCode } from '@generative-dom/plugin-markdown-code';
import { markdownList } from '@generative-dom/plugin-markdown-list';
export function Answer() {
const plugins = useMemo(
() => [
markdownBase(),
markdownInline(),
markdownHeading(),
markdownCode(),
markdownList(),
],
[],
);
const { ref, push, flush, reset } = useGenerativeDom({ plugins });
const clientRef = useRef<OpenAI>();
clientRef.current ??= new OpenAI({ dangerouslyAllowBrowser: false });
const ask = useCallback(
async (prompt: string) => {
reset();
const stream = await clientRef.current!.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: prompt }],
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) push(delta);
}
flush();
},
[push, flush, reset],
);
return (
<>
<button onClick={() => ask('Explain event loops in 3 bullets.')}>Ask</button>
<div ref={ref} className="prose" />
</>
);
}In a real app you'd run the OpenAI call from a server route and stream the result to the browser — see the SSE recipe and the fetch + ReadableStream recipe for the transport layer.
What this gets you
- Smooth token-by-token rendering that matches the pace of the model
- No full-document re-parse when a delta arrives mid-sentence
- Scroll position preserved when the container is
overflow: auto - Code fences and list markers that survive mid-syntax chunk boundaries (e.g. a
```split across two deltas)
Common pitfalls
- Forgetting
flush()— the last 1–2 frames worth of tokens stay buffered until the component unmounts or a new chunk arrives. - Re-creating the Generative DOM instance per render — memoize the
pluginsarray (as shown above). - Shipping your API key to the browser — the
openaiSDK supportsdangerouslyAllowBrowser: true, but you should proxy through your backend in production. See the SSE or fetch-streams recipes. - Using
stream.on('content', ...)v3 patterns — this recipe targets v4+. The old event-emitter style is deprecated.
Related
- Anthropic recipe — same idea with Claude
- SSE recipe — backend-proxied streaming
- Streaming guide — the pipeline internals