npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 zod
import { defineRegistry, toAiSdkTools } from 'gen-ui-chat'
import { ChatRoot, ChatRenderer, useGenUIChat } from 'gen-ui-chat/react'

Table of contents


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. useGenUIChat wraps useChat and threads in your endpoint + custom headers. useGenUIContext exposes 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 description as the tool description (this is what the LLM reads when deciding whether to call it)
  • has the component's Zod schema as both parameters and inputSchema (AI SDK v5 uses the latter; the former is kept for back-compat)
  • for display-only tools, has an execute that resolves immediately to { rendered: true } so the LLM can continue
  • for roundTrip: true tools, omits execute — the AI SDK then treats it as client-side: the LLM pauses until your component calls addToolResult (which ctx.respond does 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
}
  • status reflects the chat-level transport state. Useful for disabling buttons mid-stream.
  • toolCallId is 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.
  • priorOutput is 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's skeleton: () => ReactNode if 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> — what defineComponent returns
  • Registry<TDefs> — what defineRegistry returns
  • RenderContext — second arg to every render function

gen-ui-chat/react

<ChatRoot registry api headers? children>

React context provider. Required props:

  • registry: Registry — what to render
  • api: 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: Weather hits Open-Meteo client-side; Stocks hits 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 dev

Open 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.md for the release flow.
  • Contributions welcome — issues and PRs on https://github.com/evanbrooks0629/gen-ui.
  • LicenseMIT.

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.