Guides
Generative UI in React
Build a React chat where the model answers with charts, tables, forms and buttons, drawn as the answer streams, with clicks sent back as the next turn. Vite, Express and the web SDK, in about fifteen minutes.
By the end you have a chat in React where an answer can be a dashboard, a comparison table or a booking form. It draws while the model is still writing, and when the reader presses a button or sends a form, that goes back to the model as the next turn. The finished app is morphscreen/starter-react on GitHub.
You need Node.js 18 or later and a MorphScreen key (sign up: the free plan includes calls on our model, so no model key is needed to try it).
Make the app
npm create vite@latest my-chat -- --template react-ts
cd my-chat
npm install @morphscreen/web expressAdd the server route
The page never sees your key. It sends each run to your server, which adds the key and the model and streams the answer back:
// Two routes for the page in Express 5: /api/agent carries connectAgent's
// runs on to /v1/ui, and /api/chat streams /v1/chat/completions for a chat
// UI of your own. The key stays on the server, and the server picks the model.
import express from "express";
import { Readable } from "node:stream";const API = process.env.MORPHSCREEN_API ?? "https://api.morphscreen.com/v1";
const MODEL = process.env.MODEL ?? "deepseek/deepseek-v4.1-flash";
const HEADERS = { "Content-Type": "application/json", "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY };
const app = express();
app.use(express.json({ limit: "1mb" }));
// Sends the body on and streams the answer back. When the reader leaves, the
// call to MorphScreen stops too, so no model keeps writing for nobody.
async function relay(req, res, path, body) {
const stop = new AbortController();
res.on("close", () => stop.abort());
const upstream = await fetch(${</span><span class="t1">API</span><span class="t3">}${</span><span class="t1">path</span><span class="t3">}, { method: "POST", headers: HEADERS, body: JSON.stringify(body), signal: stop.signal });
res.status(upstream.status).type(upstream.headers.get("content-type") ?? "text/event-stream");
Readable.fromWeb(upstream.body).pipe(res);
}
app.post("/api/agent", (req, res) => relay(req, res, "/ui", { ...req.body, forwardedProps: { ...req.body.forwardedProps, model: MODEL } }));
app.post("/api/chat", (req, res) => relay(req, res, "/chat/completions", { model: MODEL, stream: true, messages: req.body.messages }));
app.listen(process.env.PORT ?? 3000);
This page uses /api/agent; /api/chat is there for when you draw answers in a chat UI of your own (below). Start it with your key in the environment:
MORPHSCREEN_KEY=ms_live_... node server.mjsSend the page's calls to it
Vite serves the page on its own port, so pass /api through to the server:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://localhost:3000" } },
});
Draw the conversation
connectAgent holds the conversation in one element: what the reader types, each answer as it streams, and buttons and forms sent back as the next turn. In React it lives in an effect:
import { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { connectAgent, loadCore, type Conversation } from "@morphscreen/web";
import "@morphscreen/web/morphscreen.css";
import coreUrl from "@morphscreen/web/morphscreen-agent.wasm?url";// Loaded once for the page: the core is about 250 KB compressed.
const core = loadCore(coreUrl);
function Chat() {
const element = useRef<HTMLDivElement>(null);
const [chat, setChat] = useState<Conversation | null>(null);
const [busy, setBusy] = useState(false);
// connectAgent holds the conversation: what the reader types, each answer
// as it streams, and buttons and forms sent back as the next turn.
useEffect(() => {
let live: Conversation | undefined;
let gone = false;
core.then((c) => {
if (gone) return;
live = connectAgent(element.current!, { core: c, url: "/api/agent" });
setChat(live);
});
return () => {
gone = true;
live?.destroy();
};
}, []);
async function send(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("message") as HTMLInputElement;
const text = input.value;
input.value = "";
setBusy(true);
try {
await chat?.send(text);
} finally {
setBusy(false);
}
}
return (
<main>
<div ref={element} />
<form onSubmit={send}>
<input name="message" placeholder="Ask anything" aria-label="Your message" required />
<button disabled={!chat || busy}>Send</button>
</form>
</main>
);
}
createRoot(document.getElementById("root")!).render(<Chat />);
?url makes Vite serve the core, morphscreen-agent.wasm, from the package along with your app.
Run it
npm run devOpen the address Vite prints and ask for something with shape: "a sales dashboard for my shop this year", "compare three phones under $500", "a form to book a table for four".
What the model can draw
The answer is Markdown with a few tags, and the API gives the model the prompt for them. With every pack on, that's 75 components: stats, charts, tables, cards, tabs, forms, quizzes, maps and more (Components). Fewer packs make a shorter prompt: set packs with the model in the server route's forwardedProps, and give connectAgent the same list (Components and packs).
Your own components
A product card from your catalog or a chart from your design system can be one of the components the model uses. Describe it once, and the core checks the model's use of it like any other (Your own components).
Your own chat UI instead
When your app already has message bubbles and its own loading states, keep them and draw each answer with <Answer>: stream /api/chat from the same server and pass the text as it grows (React). Read the stream's last chunk for the verdict and a repair (Validation and repair).
Next.js
With the App Router, the route becomes a route handler and the chat a client component (Next.js).
We run this page's code in CI against a recorded answer: the Express route, and the React page drawing the answer through it in Chrome.