Getting started
Type a prompt, get a working UI built from components you register and data you expose. This guide uses the shadcn reference catalog, so there are no components to write first. The examples use Next.js; any React app with a server route works.
Fast lane
The app this guide builds, ready to run:
npx create-next-app@latest my-app --example https://github.com/finom/uicast/tree/main/examples/starter
cd my-app
echo "AI_GATEWAY_API_KEY=your-key" > .env.local
npm run dev1. Install
npm install @uicast/expr@beta @uicast/core@beta @uicast/react@beta @uicast/shadcn-catalog@beta standard-tool ai @tanstack/react-query zod@uicast/shadcn-catalog— a reference catalog of 107 shadcn/ui components, each with the definition the model reads.@uicast/core— the engine and the prompt builders.@uicast/react— the renderer.@uicast/expr— the expression evaluator.standard-tool,ai,@tanstack/react-query— host functions, the model call, the streaming query.
The packages are beta, under the beta dist-tag.
The catalog ships its own stylesheet, so there is no Tailwind config to write:
/* Optional — preflight and the shadcn palette, if you never ran `shadcn init`. */
@import "@uicast/shadcn-catalog/theme.css";
@import "@uicast/shadcn-catalog/catalog.css";The catalog reads your theme’s CSS variables (background-color: var(--card)),
so your theme restyles it too, status colors included: --success, --warning,
--info and --destructive.
2. Expose your data
Host functions give the model your backend: one
standard-tool per function it may call. The
route reads their schemas, and the page runs them. execute runs in the
browser, so fetch over HTTP: a database client here would ship your
credentials to the client bundle.
import z from "zod";
import { standardTool } from "standard-tool";
export const listOrders = standardTool({
name: "listOrders",
description: "Orders, newest first.",
inputSchema: z.object({
page: z.number().int().min(1).default(1).meta({ description: "1-based page number" }),
status: z
.enum(["all", "open", "refunded"])
.default("all")
.meta({ description: "Filter by order status" }),
}),
outputSchema: z.object({
orders: z.array(
z.object({
id: z.number().int().meta({ description: "Order id" }),
customer: z.string().meta({ description: "Who placed it" }),
total: z.number().meta({ description: "Order total, in dollars" }),
}),
),
pageCount: z.number().int().meta({ description: "Total number of pages" }),
}),
execute: async ({ page, status }) =>
(await fetch(`/api/orders?page=${page}&status=${status}`)).json(),
});import { listOrders } from "./list-orders";
export const tools = [listOrders];inputSchema lets the model paginate and filter, and is checked before
execute runs. outputSchema tells the model the shape of the result. Every
description reaches the model word for word. See
Host functions.
3. Generate on the server
The prompt is built from the same catalog and functions the page uses. The route passes the model’s text through as it arrives, and the page reads each line as soon as it is complete:
import { createTextStreamResponse, streamText } from "ai";
import {
getCommonInstructionsPartialPrompt,
getComponentsPartialPrompt,
getExpressionsPartialPrompt,
getFunctionsPartialPrompt,
getScopePartialPrompt,
} from "@uicast/core/prompt";
import { defs } from "@uicast/shadcn-catalog/all/defs";
import { tools } from "@/tools";
const system = [
getCommonInstructionsPartialPrompt(),
getScopePartialPrompt({ kind: "page" }),
getComponentsPartialPrompt({ definitions: defs }),
getFunctionsPartialPrompt({ functions: tools }),
getExpressionsPartialPrompt(),
].join("\n\n");
export async function POST(req: Request) {
const { prompt } = await req.json();
// A bare model id resolves through the Vercel AI Gateway — set AI_GATEWAY_API_KEY.
const result = streamText({ model: "anthropic/claude-opus-5.5", system, prompt });
return createTextStreamResponse({
stream: result.textStream,
headers: { "content-type": "application/jsonl; charset=utf-8" },
});
}Keep this order; Assembling the prompt explains it.
4. Render on the page
React Query’s streamedQuery turns an AsyncIterable into an array that grows
as values arrive. streamJsonLines returns one; the renderer takes the array:
"use client";
import {
QueryClient,
QueryClientProvider,
experimental_streamedQuery as streamedQuery,
useQuery,
} from "@tanstack/react-query";
import { type ComponentEntry, streamJsonLines } from "@uicast/core";
import { Evaluator } from "@uicast/expr";
import { EntriesRenderer, RendererProvider } from "@uicast/react";
import { ConfirmModal } from "@uicast/shadcn-catalog";
import { impls } from "@uicast/shadcn-catalog/all/impls";
import { Button } from "@uicast/shadcn-catalog/ui/button";
import { Input } from "@uicast/shadcn-catalog/ui/input";
import { Skeleton } from "@uicast/shadcn-catalog/ui/skeleton";
import { useState } from "react";
import { tools } from "@/tools";
// Once, at module scope: the host functions bind on it, and it holds the parse cache.
const evaluator = new Evaluator({ functions: tools });
const fallbackComponents = {
confirm: ConfirmModal,
defaultSkeleton: () => <Skeleton className="h-4 w-20" />,
};
function Generated({ prompt }: { prompt: string }) {
const { data: entries = [] } = useQuery({
queryKey: ["generate", prompt],
enabled: prompt !== "",
// One model call per prompt: no refetch on window focus, no retry.
staleTime: Infinity,
retry: false,
queryFn: streamedQuery({
streamFn: async () => {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!res.ok || !res.body) throw new Error(`Generation failed (${res.status})`);
return streamJsonLines<ComponentEntry>(res.body);
},
}),
});
return (
<RendererProvider
implementations={impls}
evaluator={evaluator}
fallbackComponents={fallbackComponents}
>
<EntriesRenderer entries={entries} />
</RendererProvider>
);
}
export default function Home() {
const [client] = useState(() => new QueryClient());
const [draft, setDraft] = useState("");
const [prompt, setPrompt] = useState("");
return (
<QueryClientProvider client={client}>
<form onSubmit={(e) => { e.preventDefault(); setPrompt(draft); }}>
<Input value={draft} onChange={(e) => setDraft(e.target.value)} />
<Button type="submit">Generate</Button>
</form>
<Generated prompt={prompt} />
</QueryClientProvider>
);
}Show the latest orders, with a status filter.
The page fills in from the top, parents before children. A child that has not
arrived shows its parent’s skeleton, or defaultSkeleton.
Finished parts are never rebuilt, and a seed that already
fetched does not fetch again.
Where to go next
- Concepts — the element model, expressions and scopes.
- Component definition and Component implementation — your own components instead of the catalog.
- Host functions — changing data, confirmation, refetching.
- Streaming — saving entries as they stream.