Skip to content

GenUI

Server components

`@comp0/genui/rsc` streams a model's response as React Server Components: layout renders on the server, and interactive parts are client components the browser already has.

What renders where

The model only writes layout, so the server decides what each component becomes. The browser downloads code for the interactive components once, like any other client component, and every response reuses it.

  • On the server: Stack, Grid, Card, Heading, Text, List, and Image render as plain HTML, and their text grows in place while the model writes it.
  • Streamed through client parts: Tabs and Accordion arrive one tab or section at a time, each with server-rendered content.
  • On the client: controls, Form, Button, charts, tables, Query, Slot, and the rest of the catalog are predefined client components. Their props travel in the RSC stream once the model has written them, and a Form's fields still stream in through the server.
  • A component with a bound ($bind) or computed ($expr) prop renders on the client, because those values live in the shared store.
  • Nothing the model writes becomes code: it can only name catalog entries, and the server checks every prop with the same validator as the browser renderer.

A root and its views

GenUIRoot is a client component that holds what responses share: the values controls set, your host views, and the tool caller. Each GenUIView streams one response into it, such as one per turn of a conversation, so a later answer can read a choice made in an earlier one. Your own components can sit anywhere inside the root.

Exampletsx
import { GenUIRoot, GenUIView } from "@comp0/genui/rsc";
import { TripMap, TripSummary } from "./trip.js";

export function Conversation({ turns }: { turns: AsyncIterable<string>[] }) {
  return (
    <GenUIRoot call="/api/genui/tools" slots={{ map: <TripMap /> }}>
      <TripSummary />
      {turns.map((turn, index) => (
        <GenUIView key={index} stream={turn} />
      ))}
    </GenUIRoot>
  );
}
GenUIRoot props
PropTypeDescription
childrenReactNodeViews and your own components, which share the root's values.
slotsRecord<string, ReactNode>Host views the model can place by name with a Slot.
callToolCaller | stringRuns tools for Query nodes and call steps: the URL of an endpoint served by handleToolRequest, or a function.
onAction(action: GenUIAction) => voidA button was pressed, a form submitted, or a suggestion chosen. Send action.message as the next user turn.
initialStateReadonly<Record<string, unknown>>Values to start with, such as a saved state.
storeGenUIStoreA store from createGenUIStore kept outside, to share values beyond this root.
GenUIView props
PropTypeDescription
streamAsyncIterable<string>The model's response as it streams, such as the text deltas of a chat completion.

Streaming on the server

A server component renders once, so the server waits for what each component needs before it sends it.

  • Every component renders in its own Suspense boundary as soon as its type and required props have arrived, so the page fills in top-down while the model writes.
  • Text, list items, children, tabs, and sections grow in place. Every other prop must be complete before its component appears, which is why the prompt asks the model to write props first and children last.
  • A prop the model has not written yet is pending, not empty: a component waits for it until the model closes the component's object.
  • Each view is aria-busy until its stream ends. Then the response is validated once, and buttons, submits, and suggestions start to work.

Your components inside the root

Client components inside the root, and the views you place with Slot, read and write the same values as the model's controls through hooks from @comp0/genui/rsc/client: useGenUIValue(name), useGenUIState(), useGenUIStore(), and useGenUICall(). A slider the model wrote can drive your canvas with no server round trip.

trip.tsxtsx
"use client";

import { useGenUIValue } from "@comp0/genui/rsc/client";

export function TripSummary() {
  const seat = useGenUIValue("seat");
  return <p>Seat: {typeof seat === "string" ? seat : "not chosen yet"}</p>;
}

Serve tools from an endpoint

Give the root the URL of an endpoint that answers with handleToolRequest. It is plain Fetch API, so it fits a React Router resource route, a Next.js route handler, or a Worker. In React Router, prefer it to a server function: a server function re-renders the route after every call, which would stream every view again.

Exampletsx
// routes/api.genui.tools.ts, a resource route
import { handleToolRequest } from "@comp0/genui/rsc";
import { tools } from "../tools.js";

export function action({ request }: { request: Request }) {
  return handleToolRequest(tools, request);
}

Continue the conversation

Buttons, forms, and suggestions report to onAction on the root. Wrap the root in a client component and send action.message as the next turn from there, for example by navigating to a URL your server component reads.

AssistantRoot.tsxtsx
"use client";

import { GenUIRoot } from "@comp0/genui/rsc/client";
import { type ReactNode } from "react";
import { useNavigate } from "react-router";

export function AssistantRoot({ children }: { children: ReactNode }) {
  const navigate = useNavigate();
  return (
    <GenUIRoot
      call="/api/genui/tools"
      onAction={(action) => navigate(`?${new URLSearchParams({ q: action.message })}`)}
    >
      {children}
    </GenUIRoot>
  );
}