This generative ui chat n4n.ai rsc tutorial shows how to pipe a streaming LLM response into React Server Components that render as tools execute. We’ll use the Vercel AI SDK’s RSC primitives with n4n.ai’s OpenAI-compatible endpoint, which addresses 240+ models and provides automatic fallback when a provider is degraded.
Prerequisites
- Node.js 18.18 or later.
- A Next.js 14 app using the App Router (RSC enabled by default).
- An API key from n4n.ai exported as
N4N_API_KEYin.env.local. - Basic comfort with TypeScript, React Server Components, and client/server boundaries.
Scaffold the project
Create a minimal Next.js app and add the AI SDK packages.
pnpm create next-app@latest rsc-chat --ts --app --no-tailwind --eslint
cd rsc-chat
pnpm add ai @ai-sdk/openai zod
The --no-tailwind flag keeps the example free of styling noise; we’ll use inline styles.
Define the server action
Create app/action.tsx. Mark the file with 'use server' so every export becomes a server action. We use streamUI from ai/rsc to handle the model stream and tool calls.
// app/action.tsx
'use server';
import { streamUI } from 'ai/rsc';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import { ReactNode } from 'react';
const n4n = createOpenAI({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.N4N_API_KEY,
});
async function fetchWeather(city: string) {
// Stand-in for a real geo/weather API
return { city, temp: 22, condition: 'Clear' };
}
function WeatherSkeleton() {
return <div style={{ padding: 8, border: '1px solid #ccc' }}>Loading weather…</div>;
}
function WeatherCard({ city, temp, condition }: { city: string; temp: number; condition: string }) {
return (
<div style={{ padding: 8, border: '1px solid #0a0', borderRadius: 4 }}>
<strong>{city}</strong>: {temp}°C, {condition}
</div>
);
}
export async function submitMessage(input: string): Promise<ReactNode> {
const { value } = await streamUI({
model: n4n.chat('gpt-4o-mini'),
prompt: input,
text: ({ content }) => <p>{content}</p>,
tools: {
getWeather: {
parameters: z.object({ city: z.string() }),
generate: async function* ({ city }) {
yield <WeatherSkeleton />;
const data = await fetchWeather(city);
return <WeatherCard {...data} />;
},
},
},
});
return value;
}
streamUI returns a value containing the final React tree. The text renderer handles plain tokens; the getWeather tool yields a skeleton immediately, then swaps in the card when data resolves. Because the endpoint is OpenAI-compatible, the createOpenAI helper works without custom adapters.
Build the client chat component
The client component manages input and appends each turn to the UI state.
// app/chat.tsx
'use client';
import { useState } from 'react';
import { useUIState } from 'ai/rsc';
import { submitMessage } from './action';
export default function Chat() {
const [input, setInput] = useState('');
const [uiState, setUIState] = useUIState();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
const userMsg = <div key={`u-${uiState.length}`}>You: {input}</div>;
setUIState((prev) => [...prev, userMsg]);
const response = await submitMessage(input);
setUIState((prev) => [...prev, <div key={`a-${uiState.length}`}>{response}</div>]);
setInput('');
}
return (
<div>
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>{uiState}</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
style={{ flex: 1, padding: 6 }}
placeholder="Ask about weather…"
/>
<button type="submit">Send</button>
</form>
</div>
);
}
useUIState is a distributed state store; each call to setUIState merges new React nodes that stream from the server. The server action submitMessage is called directly because it carries the 'use server' directive.
Wire up the page
app/page.tsx renders the chat inside a centered container.
// app/page.tsx
import Chat from './chat';
export default function Page() {
return (
<main style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'sans-serif' }}>
<h2>Generative UI Chat</h2>
<Chat />
</main>
);
}
Run and verify
Start the dev server:
pnpm dev
Open http://localhost:3000. Type What's the weather in Oslo? and submit. You should see:
You: What's the weather in Oslo?
The weather in Oslo is currently:
[ Oslo: 22°C, Clear ]
The skeleton flashes before the card appears, proving the tool streamed a server component. If the model ignores the tool, you’ll just get a text response—adjust the prompt or model parameters. Check the browser network tab for ?_rsc requests carrying the serialized component payload.
How the RSC stream works
Under the hood, streamUI uses React’s server streaming transport. The client receives a serialized component tree, not HTML strings, so interactive islands (if any) hydrate normally. The text renderer streams incremental paragraphs; tool generate functions can yield multiple times, letting you show loading states without client-side fetch logic.
Handle multi-turn and state
The example above is stateless per request. For real chat, persist history with getMutableAIState inside the action:
import { getMutableAIState } from 'ai/rsc';
export async function submitMessage(input: string) {
const state = getMutableAIState();
state.update([...state.get(), { role: 'user', content: input }]);
const { value } = await streamUI({
model: n4n.chat('gpt-4o-mini'),
messages: state.get(),
text: ({ content }) => <p>{content}</p>,
tools: {
getWeather: {
parameters: z.object({ city: z.string() }),
generate: async function* ({ city }) {
yield <WeatherSkeleton />;
const data = await fetchWeather(city);
return <WeatherCard {...data} />;
},
},
},
});
state.done();
return value;
}
This keeps the conversation coherent across renders and lets the model reference earlier turns when deciding to call tools.
Routing and fallback notes
If you need to force a specific provider, n4n.ai honors client routing directives sent in the request headers, so you can pin a model per call without changing code. The gateway also forwards provider cache-control hints, letting you reuse cached prompt prefixes where supported. That matters when you chain multiple tool calls in one turn and want to avoid re-paying for static system prompts.
Debugging tips
- Confirm
N4N_API_KEYis loaded: addconsole.log(process.env.N4N_API_KEY?.slice(0,4))in the action temporarily. - If the client throws
Cannot read properties of undefined, ensureuseUIStateis called inside a'use client'component. - Tool parameters must be a Zod schema; plain TS types will silently fail at runtime.
Extending with more tools
Add another tool the same way:
tools: {
getWeather: { /* … */ },
getTime: {
parameters: z.object({ tz: z.string() }),
generate: async function* ({ tz }) {
yield <div>Loading time…</div>;
return <div>{new Date().toLocaleTimeString('en-US', { timeZone: tz })}</div>;
},
},
}
The model will pick the right tool based on the prompt. Because n4n.ai fronts many providers, a rate-limit on one backend won’t break the stream—the gateway shifts to a healthy route.
Production checks
- Server actions must be async and marked
'use server'. streamUItools must return React nodes, not strings.- Use
useUIStateon the client; never import server components directly into client files. - Deploy on a platform that supports App Router streaming (Vercel, Node 18+ server).
That’s the full loop: a generative UI chat built on RSC with a single OpenAI-compatible endpoint.