The same program renders in Phoenix and in a React or React Native app with the official renderer, @openuidev/react-lang. The Elixir catalog is the source of truth; the app builds its library from the library.json it generates.
examples/react_app in this repository is a working version of this guide.
The backend
Generate the spec in your build and serve it:
mix openui.gen.spec MyApp.Library --out priv/static/library.json
The backend calls the model with OpenUI.Prompt.generate(MyApp.Library, tools: tools), streams the text to the app (server-sent events or chunked HTTP), and answers tool calls at an endpoint such as POST /tools/:name. Answer them with the same functions your LiveView tool_provider uses, and check the user's authorization in each one.
The app
Build the library from the spec. Props are positional, so each component takes its prop names from schema.$defs in key order; the parser validates against the schema as it is:
import { createLibrary, defineComponent, markReactive } from "@openuidev/lang-core";
import { z } from "zod/v4";
import spec from "./library.json";
import { renderers } from "./components";
const components = Object.entries(spec.components).map(([name, { signature, description }]) => {
const bindable = new Set([...signature.matchAll(/(\w+)\??: \$binding</g)].map((m) => m[1]));
const props = Object.keys(spec.schema.$defs[name].properties ?? {}).map((key) => {
const schema = z.any();
if (bindable.has(key)) markReactive(schema);
return [key, schema];
});
return defineComponent({ name, description, props: z.object(Object.fromEntries(props)), component: renderers[name] });
});
export const library = { ...createLibrary({ components, root: spec.root }), toJSONSchema: () => spec.schema };Then render:
<Renderer
response={text}
library={library}
isStreaming={streaming}
toolProvider={{ list_tickets: (args) => fetch("/tools/list_tickets", { method: "POST", body: JSON.stringify(args) }).then((r) => r.json()) }}
onAction={(event) => send(event)}
onStateUpdate={(state) => save(state)}
/>Keeping both sides equal
- Commit
library.jsonand runmix openui.gen.spec ... --checkin CI, so it never drifts from the module and never breaks stored programs. - Keep the same props in the same order on both sides; only add props at the end.
- The state the React renderer reports (
onStateUpdate) and the one the LiveView renderer reports ({:state, state}) have the same shape, so a message stored by one side reopens on the other. examples/react_app/contractruns the official parser and this library over the same programs and compares the results in CI.