Generative UI with Claude 3.5 Sonnet via n4n.ai and RSC lets you stream React components directly from the model, not just text. This tutorial walks through a complete implementation using the Vercel AI SDK, React Server Components, and n4n.ai as the OpenAI-compatible gateway that handles routing, fallback, and per-token metering across 240+ models.
Prerequisites
- Node.js 20+ with
pnpm(or npm/yarn) - An n4n.ai API key (get one at n4n.ai)
- Basic familiarity with Next.js 14+ App Router and React Server Components
- TypeScript 5+
The stack: Next.js 14 (App Router), Vercel AI SDK 4.x, @ai-sdk/anthropic for the provider, and n4n.ai as the base URL override. No client-side bundler config changes required.
Project setup
Create a fresh Next.js app with the App Router and install dependencies:
pnpm create next-app@latest generative-ui-demo --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd generative-ui-demo
pnpm add ai @ai-sdk/anthropic zod
pnpm add -D @types/node
Configure the n4n.ai endpoint. In src/lib/gateway.ts, create a singleton Anthropic provider pointed at n4n.ai:
// src/lib/gateway.ts
import { createAnthropic } from '@ai-sdk/anthropic';
export const gateway = createAnthropic({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.N4N_API_KEY,
});
Add your key to .env.local:
N4N_API_KEY=n4n_sk_your_key_here
n4n.ai honors the standard Anthropic SDK interface, so the rest of the code stays portable. If a provider degrades, the gateway falls back automatically — no client-side retry logic needed.
Defining the component schema
Generative UI works by having the model return tool calls that map to React components. Define your component catalog with Zod schemas so the SDK can validate and type the tool calls.
// src/components/ui-catalog.ts
import { z } from 'zod';
export const components = {
card: z.object({
title: z.string(),
variant: z.enum(['default', 'bordered', 'elevated']).default('default'),
}),
metric: z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
trend: z.enum(['up', 'down', 'neutral']).optional(),
}),
table: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.string())),
}),
chart: z.object({
type: z.enum(['line', 'bar', 'area']),
data: z.array(z.object({
label: z.string(),
value: z.number(),
})),
xKey: z.string(),
yKey: z.string(),
}),
} as const;
export type ComponentName = keyof typeof components;
export type ComponentProps<T extends ComponentName> = z.infer<typeof components[T]>;
Each schema becomes a tool the model can invoke. Keep the catalog small and focused — Claude 3.5 Sonnet handles 4-6 component types reliably without hallucinating props.
Building the render layer
Create a server-side component registry that maps tool names to actual React components. This runs on the server, so it can import heavy charting libraries without shipping them to the client.
// src/components/registry.tsx
import { ComponentName, ComponentProps, components } from './ui-catalog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '@/components/ui/table';
export function renderComponent<T extends ComponentName>(
name: T,
props: ComponentProps<T>
): React.ReactElement {
switch (name) {
case 'card': {
const { title, description, variant } = props as ComponentProps<'card'>;
return (
<Card className={variant === 'elevated' ? 'shadow-lg' : variant === 'bordered' ? 'border-2' : ''}>
<CardHeader><CardTitle>{title}</CardTitle></CardHeader>
<CardContent>{description}</CardContent>
</Card>
);
}
case 'metric': {
const { label, value, trend } = props as ComponentProps<'metric'>;
const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '';
return (
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold flex items-center gap-1">{value} {trendIcon}</p>
</div>
);
}
case 'table': {
const { columns, rows } = props as ComponentProps<'table'>;
return (
<Table>
<TableHeader>
<TableRow>{columns.map((c, i) => <TableHead key={i}>{c}</TableHead>)}</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, ri) => (
<TableRow key={ri}>{row.map((cell, ci) => <TableCell key={ci}>{cell}</TableCell>)}</TableRow>
))}
</TableBody>
</Table>
);
}
default:
throw new Error(`Unknown component: ${name}`);
}
}
Add shadcn/ui components for Card and Table if you haven’t already:
pnpm dlx shadcn@latest add card table
The server action: streaming tool calls
The core of generative UI is a server action that streams tool calls as they arrive. The Vercel AI SDK’s streamUI handles the protocol — each tool call becomes a React element streamed to the client via a ReadableStream.
// src/app/actions/generate-ui.ts
'use server';
import { streamUI } from 'ai';
import { gateway } from '@/lib/gateway';
import { components } from '@/components/ui-catalog';
import { renderComponent } from '@/components/registry';
export async function generateUI(prompt: string) {
const { value: stream } = await streamUI({
model: gateway('claude-3-5-sonnet-20241022'),
system: `You are a UI generator. Respond ONLY by calling the provided tools to build interfaces.
Available components: card, metric, table, chart.
Choose components that best answer the user's request. Combine multiple components when appropriate.`,
tools: {
card: {
parameters: components.card,
generate: renderComponent,
},
metric: {
parameters: components.metric,
generate: renderComponent,
},
table: {
parameters: components.table,
generate: renderComponent,
},
chart: {
parameters: components.chart,
generate: renderComponent,
},
},
prompt,
temperature: 0.2,
maxSteps: 5,
});
return stream;
}
Key points:
maxSteps: 5allows multi-tool responses (e.g., a card + a table) without infinite loopstemperature: 0.2keeps tool selection deterministic- The
generatefunction runs on the server, sorenderComponenthas full access to server-only imports
The page: consuming the stream
In a Server Component page, invoke the action and render the returned stream directly. The AI SDK returns an AsyncIterable<React.ReactNode> that Next.js streams via RSC payload.
// src/app/page.tsx
import { generateUI } from './actions/generate-ui';
import { Suspense } from 'react';
export default async function Page() {
const prompt = 'Show me a dashboard with 3 key metrics, a summary card, and a table of recent activity';
const stream = await generateUI(prompt);
return (
<main className="container mx-auto py-8 px-4 space-y-6">
<h1 className="text-3xl font-bold">Generative UI Demo</h1>
<p className="text-muted-foreground">
Prompt: <code className="bg-muted px-1.5 rounded">{prompt}</code>
</p>
<Suspense fallback={<div className="space-y-4">Loading UI…</div>}>
<div className="space-y-4">{stream}</div>
</Suspense>
</main>
);
}
That’s it — no use client, no useEffect, no manual streaming logic. The RSC payload delivers components as they’re generated.
Expected output at this checkpoint
Start the dev server (pnpm dev) and visit http://localhost:3000. You should see:
- A loading skeleton for ~500-1500ms (first token latency via n4n.ai)
- A card component rendering with title/description
- Three metric components appearing sequentially
- A table with columns and rows populating row-by-row
The stream arrives as valid RSC chunks — inspect the Network tab to see text/x-component payloads.
Adding interactivity: client-side follow-up
Real apps need user input. Create a client component that calls the server action on demand and merges new components into the existing tree.
// src/components/generative-interface.tsx
'use client';
import { useState, FormEvent, Suspense } from 'react';
import { generateUI } from '@/app/actions/generate-ui';
export function GenerativeInterface() {
const [prompt, setPrompt] = useState('');
const [stream, setStream] = useState<React.ReactNode | null>(null);
const [history, setHistory] = useState<React.ReactNode[]>([]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!prompt.trim()) return;
setStream(null);
const newStream = await generateUI(prompt);
setStream(newStream);
setHistory(prev => [...prev, <div key={prev.length} className="border-t pt-4">{newStream}</div>]);
setPrompt('');
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="Describe the UI you want…"
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button type="submit" className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">
Generate
</button>
</form>
<Suspense fallback={<div>Streaming…</div>}>
{stream}
</Suspense>
<div className="space-y-4">{history}</div>
</div>
);
}
Update the page to use this client component:
// src/app/page.tsx
import { GenerativeInterface } from '@/components/generative-interface';
export default function Page() {
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-3xl font-bold mb-6">Generative UI with Claude 3.5 Sonnet</h1>
<GenerativeInterface />
</main>
);
}
Now users can iterate: “Add a chart showing revenue by month” → “Replace the table with a card grid” → “Make the metrics larger.”
Handling errors and fallbacks
Production code needs guardrails. Wrap the server action in a try/catch and return a fallback component on failure.
// src/app/actions/generate-ui.ts (updated)
'use server';
import { streamUI } from 'ai';
import { gateway } from '@/lib/gateway';
import { components } from '@/components/ui-catalog';
import { renderComponent } from '@/components/registry';
import { Card, CardContent } from '@/components/ui/card';
export async function generateUI(prompt: string) {
try {
const { value: stream } = await streamUI({
model: gateway('claude-3-5-sonnet-20241022'),
system: `You are a UI generator. Respond ONLY by calling the provided tools to build interfaces.
Available components: card, metric, table, chart.
Choose components that best answer the user's request. Combine multiple components when appropriate.`,
tools: {
card: { description: 'Render a content card', parameters: components.card, generate: renderComponent },
metric: { description: 'Display a single metric', parameters: components.metric, generate: renderComponent },
table: { description: 'Render a data table', parameters: components.table, generate: renderComponent },
chart: { description: 'Render a chart', parameters: components.chart, generate: renderComponent },
},
prompt,
temperature: 0.2,
maxSteps: 5,
});
return stream;
} catch (error) {
console.error('Generative UI error:', error);
return (
<Card className="border-destructive">
<CardContent className="text-destructive">
Failed to generate UI. Please try a simpler request.
</CardContent>
</Card>
);
}
}
n4n.ai returns standard Anthropic error codes (rate_limit_error, overloaded_error, etc.). The gateway’s automatic fallback means you rarely hit provider-level failures, but network errors and schema validation failures still need handling.
Type-safe tool results with useUIState
For client-side state that mirrors the server stream, the AI SDK provides useUIState. This keeps the component tree in React state so you can manipulate it (delete, reorder, edit) before the next generation.
// src/components/generative-interface.tsx (enhanced)
'use client';
import { useUIState } from 'ai/rsc';
import { generateUI } from '@/app/actions/generate-ui';
export function GenerativeInterface() {
const [ui, setUI] = useUIState<React.ReactNode[]>([]);
const [prompt, setPrompt] = useState('');
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const stream = await generateUI(prompt);
setUI(prev => [...prev, stream]);
setPrompt('');
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="Describe the UI you want…"
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button type="submit" className="px-4 py-2 bg-primary text-primary-foreground rounded-lg">
Generate
</button>
</form>
<div className="space-y-4">
{ui.map((node, i) => (
<div key={i} className="border-t pt-4">{node}</div>
))}
</div>
</div>
);
}
useUIState works with RSC — the initial value comes from the server, subsequent updates are client-side. This pattern scales to collaborative editing, undo/redo, and persistence.
Routing directives and cache control
n4n.ai forwards provider cache-control hints and honors client routing directives. If you need to pin a specific provider (e.g., Anthropic direct for lower latency on Sonnet), pass the provider header:
// src/lib/gateway.ts (extended)
export const gateway = createAnthropic({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.N4N_API_KEY,
headers: {
'x-n4n-provider': 'anthropic', // optional: pin to Anthropic
'x-n4n-cache-control': 'no-cache', // optional: bypass cache for fresh generations
},
});
The gateway also exposes per-token usage in response headers (x-n4n-usage-prompt-tokens, x-n4n-usage-completion-tokens) — useful for dashboards and cost attribution.
Testing the full flow
Run the dev server and try these prompts in order:
-
"Create a dashboard with 3 metrics: revenue ($127k, up), users (2,841, up), churn (2.1%, down), plus a card summarizing Q4 performance, and a table of top 5 customers by revenue"- Expect: 3 metrics, 1 card, 1 table — all streaming in ~2-3 seconds
-
"Replace the table with a bar chart showing monthly revenue for the last 6 months"- Expect: Chart component replaces table; previous metrics and card persist
-
"Make the metrics larger and add a trend sparkline to each"- Expect: Model may hallucinate a
sparklineprop — your Zod schema rejects it, the SDK retries, and you get valid metrics
- Expect: Model may hallucinate a
The schema validation loop is automatic: invalid tool calls trigger a model retry with the error message. Keep schemas strict.
Deployment notes
- Set
N4N_API_KEYin your platform’s environment variables (Vercel, Railway, Fly.io, etc.) - The gateway endpoint is
https://api.n4n.ai/v1— no VPC or private link needed - For production, enable Next.js output standalone:
output: 'standalone'innext.config.js - n4n.ai rate limits are generous (thousands of RPM), but implement exponential backoff in the client for resilience
What’s next
- Add authentication and per-user usage tracking via n4n.ai’s metering headers
- Extend the component catalog with domain-specific components (pricing cards, feature grids, forms)
- Implement
onToolCallcallbacks for analytics — log every generated component type - Explore
streamUI’sonFinishfor post-generation side effects (database writes, webhooks)
The pattern — schema-defined tools, server-side render functions, RSC streaming — generalizes to any model that supports tool calling. Swap gateway('claude-3-5-sonnet-20241022') for gateway('gpt-4o') or gateway('llama-3.1-405b') and the same code works. n4n.ai handles the routing.