gen-ui-chat
v0.1.1
Published
Headless generative UI for Next.js + Vercel AI SDK. Register React components your LLM can render via validated JSON. Bring your own chat UI.
Maintainers
Readme
gen-ui-chat
Generative UI for chat, built for Next.js + the Vercel AI SDK. Register React components your LLM can render by emitting validated JSON. One install, two import paths, ~350 lines of headless library code.
pnpm add gen-ui-chat ai @ai-sdk/react @ai-sdk/openai zodimport { defineRegistry, toAiSdkTools } from 'gen-ui-chat'
import { ChatRoot, ChatRenderer, useGenUIChat } from 'gen-ui-chat/react'Table of contents
- What it gives you
- The mental model
- Quickstart (three files)
- Concepts
- Recipes
- API reference
- Persistence
- The example app
- FAQ
- How it compares
- Versioning, contributing, license
What it gives you
- A registry abstraction. Declare each renderable component once, with a Zod schema, a description, and a render function. The same registry powers both the server-side tool definitions and the client-side renderer.
- Validated JSON, all the way through. The LLM can only return prop shapes that satisfy your Zod schemas. The renderer re-validates on every render, so a schema bump can't crash the chat — it falls back to a small error UI.
- Hooks for the Vercel AI SDK.
useGenUIChatwrapsuseChatand threads in your endpoint + custom headers.useGenUIContextexposes the registry anywhere below<ChatRoot>. - Headless. No bubbles, no avatars, no inline styles, no
<Chat>component to fight with. The library renders the component the LLM picked; you bring the UI around it.
What's deliberately not in the box: a chat UI, a primitives library, a CLI, message persistence, a fork of the AI SDK. gen-ui-chat is a thin lens between the AI SDK and your React tree, not a framework.
The mental model
You define The LLM picks You render
─────────── ───────────── ──────────
registry.tsx → AI SDK tool → tool-call part → ChatRenderer
{ Weather: { name, params, { type, input, validates,
schema, execute } toolCallId } renders
render } → <Weather {...} />One source of truth (registry.tsx) feeds two consumers: the server (where toAiSdkTools(registry) turns each entry into an AI SDK tool the LLM can call) and the client (where <ChatRenderer json={...} /> validates the LLM's args and renders the matching React component into your tree).
Display-only components return immediately. Round-trip components (think <Confirm>, <ChooseOne>) pause the LLM until the user picks; the user's answer becomes the tool result and the LLM continues. The library handles the wiring; you just call ctx.respond(answer) from your component.
Quickstart (three files)
1. Define the registry
The registry maps each component name to a Zod schema and a render function. Same file, imported by both server and client.
// app/gen-ui/registry.tsx
import { defineComponent, defineRegistry } from 'gen-ui-chat'
import { z } from 'zod'
import { Weather } from '@/components/Weather'
export const registry = defineRegistry({
Weather: defineComponent({
name: 'Weather',
description: 'Show the current weather for a city.',
schema: z.object({
city: z.string().describe('City name, e.g. "Tokyo" or "Paris"'),
units: z.enum(['c', 'f']).optional(),
}),
render: ({ city, units }) => <Weather city={city} units={units} />,
}),
})2. Wire the API route
toAiSdkTools(registry) converts the registry into an AI SDK ToolSet. The rest is plain AI SDK.
// app/api/chat/route.ts
import { streamText, convertToModelMessages } from 'ai'
import { openai } from '@ai-sdk/openai'
import { toAiSdkTools } from 'gen-ui-chat'
import { registry } from '@/app/gen-ui/registry'
export async function POST(req: Request) {
const { messages } = await req.json()
return streamText({
model: openai('gpt-4o-mini'),
messages: convertToModelMessages(messages),
tools: toAiSdkTools(registry),
}).toUIMessageStreamResponse()
}3. Render the chat (your UI)
You own the chat layout. The library gives you <ChatRoot> (context + transport config) and <ChatRenderer> (validates one tool call's JSON and renders the matching component). Everything else is whatever React you want.
// app/page.tsx
'use client'
import { useState } from 'react'
import {
ChatRoot,
ChatRenderer,
useGenUIChat,
useGenUIContext,
} from 'gen-ui-chat/react'
import { registry } from '@/app/gen-ui/registry'
export default function Page() {
return (
<ChatRoot registry={registry} api="/api/chat">
<Chat />
</ChatRoot>
)
}
function Chat() {
const { registry } = useGenUIContext()
const { messages, sendMessage, status } = useGenUIChat()
const [text, setText] = useState('')
return (
<div>
{messages.map((m) => (
<div key={m.id} data-role={m.role}>
{m.parts.map((p, i) => {
if (p.type === 'text') return <p key={i}>{p.text}</p>
if (!p.type.startsWith('tool-')) return null
const toolName = p.type.replace(/^tool-/, '')
const ready =
p.state === 'input-available' || p.state === 'output-available'
return (
<ChatRenderer
key={i}
registry={registry}
json={{ type: toolName, props: p.input ?? {} }}
state={ready ? 'complete' : 'pending'}
toolCallId={p.toolCallId}
/>
)
})}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault()
if (text.trim()) {
sendMessage({ text })
setText('')
}
}}
>
<input
value={text}
onChange={(e) => setText(e.target.value)}
disabled={status === 'streaming'}
/>
<button disabled={status === 'streaming'}>Send</button>
</form>
</div>
)
}Now ask the chat "what's the weather in Tokyo" and you'll get a <Weather city="Tokyo" /> rendered inline.
For a polished version with bubbles, avatars, auto-scroll, typing indicators, suggested prompts, and a provider selector, see examples/nextjs-app-router/app/page.tsx in this repo.
Concepts
The registry
A registry is just an object mapping component names to component definitions. Build it with defineRegistry:
const registry = defineRegistry({
Weather: defineComponent({ /* ... */ }),
Stocks: defineComponent({ /* ... */ }),
CodeBlock: defineComponent({ /* ... */ }),
})defineComponent takes:
defineComponent({
name: 'Weather', // must match the registry key
description: 'Show weather for a city', // shown to the LLM as the tool description
schema: z.object({ city: z.string() }), // the tool's parameters; LLM args must satisfy this
render: (props, ctx) => <Weather {...props} />,
roundTrip: false, // default — display-only
supportsPartial: false, // default — wait for full JSON before rendering
skeleton: () => <WeatherSkeleton />, // optional — shown while the tool call is streaming
})The name field must equal the registry key (defineRegistry enforces this — it throws with a helpful message if you mismatch). This redundancy is intentional: it keeps a component definition self-describing if you ever pass one around outside the registry.
Tools, validated
toAiSdkTools(registry) returns a Record<string, ToolDef> you hand to streamText({ tools }). Each entry:
- has the component's
descriptionas the tool description (this is what the LLM reads when deciding whether to call it) - has the component's Zod
schemaas bothparametersandinputSchema(AI SDK v5 uses the latter; the former is kept for back-compat) - for display-only tools, has an
executethat resolves immediately to{ rendered: true }so the LLM can continue - for
roundTrip: truetools, omitsexecute— the AI SDK then treats it as client-side: the LLM pauses until your component callsaddToolResult(whichctx.responddoes for you)
Every prop on its way to the rendered component goes through Zod. If the LLM hallucinates a field or wrong type, you get a <Fallback> UI with the parse error, not a crash.
The render context
Every render function gets a second arg:
type RenderContext = {
status: 'streaming' | 'awaiting_response' | 'idle'
toolCallId?: string
respond?: (output: unknown) => void
priorOutput?: unknown
}statusreflects the chat-level transport state. Useful for disabling buttons mid-stream.toolCallIdis the stable ID for this specific tool call. Use it to dedupe side effects across re-renders (Strict Mode, parent updates, message-history replay).respond(output)is defined for round-trip tools that are still waiting for a user answer. Calling it sends the answer back to the LLM as the tool result.priorOutputis the previously-submitted answer on a round-trip tool that has already been answered (e.g. when rehydrating a thread from history). When set, your component should render in "already answered" mode rather than offering live buttons.
Display-only vs. round-trip components
| | Display-only (default) | Round-trip (roundTrip: true) |
|---|---|---|
| LLM picks the args | yes | yes |
| User can interact | yes (the component is a real React component) | yes |
| User's interaction is sent back to the LLM | no — handle locally in app state | yes — via ctx.respond(answer) |
| Server tool's execute | resolves to { rendered: true } | not defined; LLM waits for addToolResult |
| Examples | Weather, Stocks, TaskList, Todos | Confirm, ChooseOne, a <DateRangePicker>, a form |
Pick "round-trip" when the LLM needs to react to the user's choice ("user clicked Yes, so call ClearCompletedTodos"). Pick "display-only" when the chat just shows information and any interaction is private to the app.
Skeleton-on-call streaming
The AI SDK emits a tool-call part as soon as the LLM commits to a tool, even before the args are fully streamed. Your <ChatRenderer> can render two states:
state="pending"— args not complete yet. The library renders the component'sskeleton: () => ReactNodeif you provided one, otherwise the unstyled<Skeleton>placeholder.state="complete"— args complete and validated. The real component renders.
The pattern in your chat loop is one line:
const ready = p.state === 'input-available' || p.state === 'output-available'
<ChatRenderer state={ready ? 'complete' : 'pending'} {...} />Custom shaped skeletons make the transition feel intentional. The example app's app/gen-ui/skeletons.tsx shows one per registered component.
Recipes
A component that fetches its own data
The LLM doesn't need to know live data — it just picks an identifier. The component fetches when it mounts:
// components/Stocks.tsx
'use client'
import { useEffect, useState } from 'react'
export function Stocks({ symbol }: { symbol: string }) {
const [quote, setQuote] = useState<Quote | null>(null)
useEffect(() => {
fetch(`/api/stocks?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json())
.then(setQuote)
}, [symbol])
if (!quote) return <StocksSkeleton />
return <Card data={quote} />
}// registry.tsx
Stocks: defineComponent({
name: 'Stocks',
description: 'Live stock quote with daily change and a sparkline.',
schema: z.object({ symbol: z.string().describe('Yahoo Finance ticker') }),
render: (props) => <Stocks {...props} />,
})The LLM only stores { symbol: 'AAPL' }. On replay the component re-fetches fresh data — usually what you want for stocks/weather/etc.
A component that reads your app state
LLM-rendered components live in your React tree. Set up a Context above <ChatRoot> and any component (registered or not) can read it:
// app/state/todos.tsx
'use client'
import { createContext, useContext, useReducer, useEffect } from 'react'
const TodosContext = createContext<TodosValue | null>(null)
export const useTodos = () => useContext(TodosContext)!
export function TodosProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, { todos: [] })
useEffect(() => { /* hydrate from localStorage */ }, [])
useEffect(() => { /* persist to localStorage */ }, [state])
return <TodosContext.Provider value={{ /* ... */ }}>{children}</TodosContext.Provider>
}// app/page.tsx
<TodosProvider>
<ChatRoot registry={registry} api="/api/chat">
<Chat />
</ChatRoot>
</TodosProvider>// components/Todos.tsx — registered with the LLM
'use client'
import { useTodos } from '@/app/state/todos'
export function Todos() {
const { todos, toggle, remove } = useTodos()
// ... render the user's persistent todos. The LLM doesn't know about ids
// or localStorage — it just calls Todos() and the component does the rest.
}The library is completely uninvolved in your state — useTodos() is plain React.
Letting the LLM CRUD your app state
For the LLM to change state (not just render it), define "action tools" whose render is a small confirmation pill, and whose side effect runs once in useEffect, deduped by toolCallId:
// app/state/executedTools.ts
const executed = new Set<string>()
export function executeOnce(id: string | undefined, fn: () => void) {
if (!id || executed.has(id)) return
executed.add(id)
fn()
}// components/actions/AddTodosAction.tsx
'use client'
import { useEffect } from 'react'
import { useTodos } from '@/app/state/todos'
import { executeOnce } from '@/app/state/executedTools'
export function AddTodosAction({
items,
toolCallId,
}: {
items: { text: string }[]
toolCallId?: string
}) {
const { addMany } = useTodos()
useEffect(() => {
executeOnce(toolCallId, () => addMany(items))
}, [toolCallId, items, addMany])
return <Pill>Added {items.length} todos</Pill>
}// registry.tsx
AddTodos: defineComponent({
name: 'AddTodos',
description: "Add new todos to the user's list. Use for 'add buy milk'.",
schema: z.object({
items: z.array(z.object({ text: z.string() })).min(1).max(20),
}),
render: (props, ctx) => <AddTodosAction {...props} toolCallId={ctx.toolCallId} />,
})Now "add buy milk and walk the dog" triggers AddTodos and your state updates. Same pattern works for Complete, Remove, ClearCompleted, etc.
The toolCallId dedupe matters. Without it, React Strict Mode double-fires useEffect and you get duplicate inserts; replaying a thread from history would fire every action a second time.
The example app's examples/nextjs-app-router/components/actions/ has the full set for todos + watchlist.
Asking the user (round-trip)
Set roundTrip: true and your component receives ctx.respond for the user's answer:
// components/Confirm.tsx
'use client'
import { useState } from 'react'
export function Confirm({
question,
respond,
priorOutput,
}: {
question: string
respond?: (output: { confirmed: boolean }) => void
priorOutput?: { confirmed: boolean }
}) {
const [submitted, setSubmitted] = useState(priorOutput ?? null)
const pick = (confirmed: boolean) => {
setSubmitted({ confirmed })
respond?.({ confirmed })
}
if (submitted) return <Recorded>You said: {submitted.confirmed ? 'Yes' : 'No'}</Recorded>
return (
<div>
<p>{question}</p>
<button onClick={() => pick(true)}>Yes</button>
<button onClick={() => pick(false)}>No</button>
</div>
)
}// registry.tsx
Confirm: defineComponent({
name: 'Confirm',
description: 'Ask the user a yes/no question and wait for their answer.',
schema: z.object({ question: z.string() }),
roundTrip: true,
render: (props, ctx) => (
<Confirm
{...props}
respond={ctx.respond as (output: { confirmed: boolean }) => void}
priorOutput={ctx.priorOutput as { confirmed: boolean } | undefined}
/>
),
})In the chat loop, when the part is in input-available state, also pass respond to <ChatRenderer>:
const respond =
p.state === 'input-available' && p.toolCallId
? (output: unknown) =>
addToolResult({ tool: toolName, toolCallId: p.toolCallId!, output })
: undefined
const priorOutput = p.state === 'output-available' ? p.output : undefined
<ChatRenderer
registry={registry}
json={{ type: toolName, props: p.input ?? {} }}
state={ready ? 'complete' : 'pending'}
toolCallId={p.toolCallId}
respond={respond}
priorOutput={priorOutput}
/>Now if the LLM says "Are you sure you want to clear all your completed todos?" it pauses, the user clicks Yes, the answer flows back to the LLM, and the LLM can decide to call ClearCompletedTodos next.
Sending custom headers per request
<ChatRoot> accepts a headers prop. Pass a function if you want it called on every send (so it reads the latest React state):
const providerRef = useRef(provider)
providerRef.current = provider
<ChatRoot
registry={registry}
api="/api/chat"
headers={() => ({
'x-provider': providerRef.current,
'x-watchlist': symbolsRef.current.join(','),
})}
>
...
</ChatRoot>On the route handler, read whatever you need:
export async function POST(req: Request) {
const provider = req.headers.get('x-provider') ?? 'openai'
const model = provider === 'anthropic'
? anthropic('claude-sonnet-4-6')
: openai('gpt-4o-mini')
// ...
}The example app uses this to thread the current provider, a stock watchlist, and a JSON-encoded todo summary into the system prompt on every turn — without bundling them into chat messages.
Single-shot UI (no chat)
For "give me one rich card, not a chat" use cases (search-result cards, dashboards, single-page agent outputs), use toComponentSchema(registry) with generateObject:
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { toComponentSchema } from 'gen-ui-chat'
import { registry } from '@/app/gen-ui/registry'
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: toComponentSchema(registry),
prompt: "Show today's NVDA card.",
})
// object is a { type, props } that you can hand straight to <ChatRenderer />.toComponentSchema(registry) returns a Zod schema that's a discriminated union over { type: 'Name', props: <Name's schema> } for every entry. Pair with streamObject to stream partial UI.
API reference
gen-ui-chat (core)
defineRegistry(components) → Registry
Wraps a record of defineComponent outputs. Throws if any component's name doesn't match its registry key.
defineComponent(input) → ComponentDefinition
Declares a renderable component. Required fields: name, description, schema, render. Optional: roundTrip, supportsPartial, skeleton.
toAiSdkTools(registry) → ToolSet
Converts a registry into AI SDK v5 tool definitions. Drop into streamText({ tools }). Display-only tools get execute: () => ({ rendered: true }); roundTrip: true tools omit execute (client resolves with addToolResult).
toComponentSchema(registry) → ZodTypeAny
Zod schema for a single ComponentJson from this registry. Use with generateObject / streamObject for single-shot UI. Single-entry registries return a z.object; multi-entry returns a z.union over each entry's { type, props } shape.
validateComponentJson(registry, input) → { ok: true, value } | { ok: false, reason }
Re-parse a stored or transmitted component JSON against the registry. Useful when rehydrating a persisted thread.
Types
ComponentJson<TName, TProps>—{ type: TName, props: TProps }ComponentDefinition<TName, TSchema>— whatdefineComponentreturnsRegistry<TDefs>— whatdefineRegistryreturnsRenderContext— second arg to every render function
gen-ui-chat/react
<ChatRoot registry api headers? children>
React context provider. Required props:
registry: Registry— what to renderapi: string— the chat endpoint URL
Optional:
headers?: Record<string, string> | Headers | (() => Record<string, string> | Headers)— per-request headers. Pass a function if it should re-read on every send.
<ChatRenderer registry json state? status? toolCallId? respond? priorOutput?>
Validates one component JSON against the registry and renders the matching component (wrapped in a <RenderBoundary>). Falls back to <Fallback> on validation failure or unknown component, <Skeleton> (or the component's own skeleton) when state="pending".
<RenderBoundary type children>
Class-based error boundary. If a component throws at render, swaps to <Fallback> with the error message. Resets when the component type changes.
<Fallback reason? className?>
Unstyled error UI. Renders semantic markup with role="alert", data-testid="gen-ui-fallback", and your optional className.
<Skeleton className?>
Unstyled loading placeholder. data-testid="gen-ui-skeleton", aria-busy="true".
useGenUIChat()
Returns AI SDK v5's useChat output, configured with the api and headers from <ChatRoot>. Includes messages, sendMessage, status, and addToolResult (which you call from respond to answer round-trip tools).
useGenUIContext()
Returns { registry, api, headers } from the nearest <ChatRoot>. Throws if called outside one.
Persistence
gen-ui-chat is stateless by design. The AI SDK's message format is plain JSON; you persist messages to your stack (Postgres, Supabase, Convex, etc.) and rehydrate without burning an LLM turn.
// Save
useEffect(() => {
if (messages.length) save(threadId, messages)
}, [messages])
// Load
const stored = await load(threadId)
const { messages } = useChat({ messages: stored, transport })There are three small things to know — live state vs. snapshot for components that read app state, toolCallId dedupe for side-effect tools, and component-internal state not replaying. Full write-up plus a working Postgres reference in docs/persistence.md.
The example app
examples/nextjs-app-router/ is a styled reference implementation. It demonstrates:
- 9 registered components:
Weather,Stocks,RecipeIdea,CodeBlock,TaskList,Todos,ComparisonTable,Confirm,ChooseOne - 7 action tools that CRUD app state:
AddTodos,CompleteTodo,UncompleteTodo,RemoveTodo,ClearCompletedTodos,AddToWatchlist,RemoveFromWatchlist - 2 round-trip tools:
Confirm,ChooseOne - Two-way live data:
Weatherhits Open-Meteo client-side;Stockshits a Yahoo Finance proxy - App-state demos: a stock watchlist and a persistent todo list, both
useReducer+ Context + localStorage - Custom chat UI: bubbles, avatars, auto-scroll, typing dots, suggested prompts, provider selector (OpenAI / Anthropic)
- Custom shaped skeletons per component
- Per-request headers (
x-provider,x-watchlist,x-todos-summary,x-todos-full) that thread state into the system prompt
To run it:
git clone https://github.com/evanbrooks0629/gen-ui
cd gen-ui
pnpm install
pnpm -F gen-ui-chat build
cp examples/nextjs-app-router/.env.local.example examples/nextjs-app-router/.env.local
# add your OPENAI_API_KEY (default) or set PROVIDER=anthropic + ANTHROPIC_API_KEY
pnpm -F example-nextjs-app-router devOpen http://localhost:3000 and try:
- "What's the weather in Tokyo right now?" → live Weather card
- "How is NVDA doing today?" → live Stocks card with sparkline
- "What should I cook tonight?" → RecipeIdea with a "Get another" button
- "Add buy milk and walk the dog to my todos." → AddTodos action + Todos card
- "Should I delete all my completed todos?" → Confirm round-trip, then ClearCompletedTodos
- "Show me a TypeScript snippet for debouncing a function." → CodeBlock
- "Compare iPhone 15 Pro vs Pixel 8 Pro vs Galaxy S24 Ultra." → ComparisonTable
FAQ
Does this work without Next.js?
The core (gen-ui-chat) does — it's just registry + Zod + AI SDK helpers. The React package (gen-ui-chat/react) works in any React 19 app on top of the AI SDK's useChat. Next.js gets the closest match because of App Router + route handlers, but it isn't required.
Does this work with the AI SDK's RSC mode (streamUI)?
Phase 1 deliberately picked the JSON-first path with streamText + tool calls. The big win is that messages stay serializable (rehydration without re-running the LLM). RSC is on the table for a future major if there's demand.
Can I use Vercel, OpenAI, Anthropic, Google, local? Any provider that ships an AI SDK provider package. Switching is a one-line edit in your route — the library doesn't care. The example app has both OpenAI and Anthropic wired and a UI selector for swapping mid-thread.
What about CSS / theming?
The library ships zero styles. <Fallback> and <Skeleton> are semantic markup with optional className props and stable data-testids. Bring whatever utility set, design system, or hand-rolled CSS you already use.
Why no built-in primitives library (Form, Button, Table)?
Every primitive is a design opinion. Once we made the library headless, primitives became consumer territory. The example app has hand-rolled versions of Form, Table, Card, etc. that you can copy in.
The LLM keeps inventing data instead of calling my fetching component. How do I stop it?
Two things: (1) put "you only pass the {identifier}; the component fetches live data — never invent X" in the system prompt, and (2) make sure your schema only accepts the identifier. If the schema has a price: z.number() field, the LLM will fill it in.
Can I persist a thread and replay it later?
Yes — useChat accepts a messages initializer. The library re-validates every part on each render, so a schema change since the message was created falls back to <Fallback> rather than crashing. Full write-up in docs/persistence.md.
How do I dedupe side effects from action tools on replay?
A module-level Set<string> keyed by toolCallId. The example has app/state/executedTools.ts — 10 lines. If you persist threads, swap the in-memory Set for sessionStorage or a server-side "executed" marker so already-executed actions don't re-fire after a page reload.
Why two import paths (gen-ui-chat and gen-ui-chat/react)?
So a server-only route handler doesn't pull React or @ai-sdk/react into its bundle. The core entry has no React dep; the React entry imports core internally. They're built as separate tsup entries and resolved via the exports field in package.json.
How it compares
| | gen-ui-chat | CopilotKit | AI SDK alone | |---|---|---|---| | LLM renders React components | ✅ | ✅ | (you wire it) | | Zod-validated tool args | ✅ | partial | ✅ | | Round-trip tools (user → LLM) | ✅ | ✅ | manual | | Headless / no chat UI | ✅ | ❌ ships chat UI | ✅ | | Built for AI SDK v5 | ✅ | ❌ separate runtime | ✅ | | Library size | ~350 lines | ~3k+ lines | core only | | Persistence story | docs + caveats, plain JSON | hosted backend | docs + caveats |
If you want a hosted backend and a polished default chat UI out of the box, CopilotKit is the bigger bet. If you've already adopted the Vercel AI SDK and want a tiny adapter that lets the LLM render your components — that's this.
Versioning, contributing, license
- Versioning is semver, manual bumps. See
docs/publishing.mdfor the release flow. - Contributions welcome — issues and PRs on https://github.com/evanbrooks0629/gen-ui.
- License — MIT.
Acknowledgements
Inspired by CopilotKit's useCopilotAction and the Vercel AI SDK team's generative-UI demos. Built so you can have what they have without committing to either framework.
