n4nAI

Next.js App Router generative UI with Vercel AI SDK

Build generative UI in Next.js App Router using Vercel AI SDK with React Server Components, streaming responses, and tool calling.

n4n Team3 min read767 words

Audio narration

Coming soon — every post will get a voice note here.

Generative UI shifts the mental model from “chat with an LLM” to “render components the LLM chooses.” The Vercel AI SDK makes this practical in the Next.js App Router by combining React Server Components, streaming, and tool calling into a single pattern. This tutorial builds a working example you can extend.

Prerequisites

  • Node.js 20+
  • A Next.js 14+ project with App Router (npx create-next-app@latest --typescript --tailwind --eslint --app)
  • An OpenAI-compatible API key (or any provider the AI SDK supports)
  • Familiarity with React Server Components and async/await

Install the AI SDK packages:

npm install ai @ai-sdk/openai zod

Project structure

app/
├── api/
│   └── chat/
│       └── route.ts          # Server endpoint that streams tool calls
├── components/
│   ├── Chat.tsx              # Client component handling the conversation
│   ├── WeatherCard.tsx       # Generative UI component #1
│   └── StockChart.tsx        # Generative UI component #2
├── actions.ts                # Server actions for tool execution
└── page.tsx                  # Page composition

Define the tool schema

Tools are the contract between the model and your UI. Use Zod for validation — the AI SDK infers the JSON schema automatically.

// app/actions.ts
import { z } from "zod";
import { createTool } from "ai";

export const getWeather = createTool({
  parameters: z.object({
    location: z.string().describe("City and state, e.g. 'San Francisco, CA'"),
    unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit"),
  }),
  execute: async ({ location, unit }) => {
    // In production, call a real weather API
    const temp = unit === "celsius" ? 18 : 64;
    return {
      location,
      temperature: temp,
      unit,
      condition: "Partly cloudy",
      humidity: 65,
    };
  },
});

export const getStockPrice = createTool({
  parameters: z.object({
    symbol: z.string().describe("Stock ticker symbol, e.g. 'AAPL'"),
  }),
  execute: async ({ symbol }) => {
    // In production, call a real market data API
    const prices: Record<string, number> = {
      AAPL: 182.52,
      GOOGL: 141.38,
      MSFT: 404.23,
      TSLA: 248.15,
    };
    return {
      symbol: symbol.toUpperCase(),
      price: prices[symbol.toUpperCase()] ?? 100.00,
      change: (Math.random() - 0.5) * 10,
      timestamp: new Date().toISOString(),
    };
  },
});

The execute function runs on the server. Keep it pure — no side effects beyond the external API call.

Build the generative UI components

Each tool maps to a React component. These render on the server and stream to the client as part of the assistant message.

// app/components/WeatherCard.tsx
interface WeatherData {
  location: string;
  temperature: number;
  unit: "celsius" | "fahrenheit";
  condition: string;
  humidity: number;
}

export function WeatherCard({ data }: { data: WeatherData }) {
  const unitSymbol = data.unit === "celsius" ? "°C" : "°F";
  return (
    <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
      <div className="flex items-baseline gap-2">
        <h3 className="text-lg font-semibold text-slate-900">{data.location}</h3>
        <span className="text-sm text-slate-500">{data.condition}</span>
      </div>
      <div className="mt-2 flex items-end gap-4">
        <span className="text-4xl font-mono font-bold text-slate-900">
          {data.temperature}{unitSymbol}
        </span>
        <div className="text-sm text-slate-500">
          <p>Humidity: {data.humidity}%</p>
        </div>
      </div>
    </div>
  );
}
// app/components/StockChart.tsx
interface StockData {
  symbol: string;
  price: number;
  change: number;
  timestamp: string;
}

export function StockChart({ data }: { data: StockData }) {
  const isPositive = data.change >= 0;
  const changeClass = isPositive ? "text-green-600" : "text-red-600";
  const changePrefix = isPositive ? "+" : "";
  return (
    <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
      <div className="flex justify-between items-baseline">
        <h3 className="text-lg font-semibold text-slate-900">{data.symbol}</h3>
        <time className="text-xs text-slate-400">
          {new Date(data.timestamp).toLocaleTimeString()}
        </time>
      </div>
      <div className="mt-2 flex items-baseline gap-3">
        <span className="text-3xl font-mono font-bold text-slate-900">
          ${data.price.toFixed(2)}
        </span>
        <span className={`text-sm font-medium ${changeClass}`}>
          {changePrefix}{data.change.toFixed(2)}%
        </span>
      </div>
      <div className="mt-3 h-2 bg-slate-100 rounded-full overflow-hidden">
        <div
          className={`h-full ${isPositive ? "bg-green-500" : "bg-red-500"}`}
          style={{ width: `${Math.min(Math.abs(data.change) * 10, 100)}%` }}
        />
      </div>
    </div>
  );
}

These are plain server components. No use client directive — they render during the stream.

Create the chat API route

The route uses streamText with the tools option. The AI SDK handles tool calling, result injection, and streaming the final response with embedded component data.

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { getWeather, getStockPrice } from "@/app/actions";

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    tools: {
      getWeather,
      getStockPrice,
    },
    system: `You are a helpful assistant that can check weather and stock prices.
When users ask about weather or stocks, call the appropriate tool.
The tool results will be rendered as interactive components automatically.`,
  });

  return result.toDataStreamResponse();
}

The toDataStreamResponse() method returns a ReadableStream that the client consumes. It includes both text deltas and tool result objects.

Build the client chat component

The client component uses useChat from ai/react. It handles the message list, input, and streaming state.

// app/components/Chat.tsx
"use client";

import { useChat } from "ai/react";
import { WeatherCard } from "./WeatherCard";
import { StockChart } from "./StockChart";

export function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: "/api/chat",
  });

  return (
    <div className="flex flex-col h-[600px] rounded-xl border border-slate-200 bg-white overflow-hidden">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex gap-3 ${message.role === "assistant" ? "" : "justify-end"}`}
          >
            <div
              className={`max-w-[70%] rounded-2xl px-4 py-2 ${
                message.role === "user"
                  ? "bg-blue-600 text-white rounded-br-none"
                  : "bg-slate-100 text-slate-900 rounded-bl-none"
              }`}
            >
              {message.role === "assistant" && message.parts?.map((part, i) => {
                if (part.type === "text") {
                  return <p key={i} className="whitespace-pre-wrap">{part.text}</p>;
                }
                if (part.type === "tool-result") {
                  if (part.toolName === "getWeather") {
                    return <WeatherCard key={i} data={part.result} />;
                  }
                  if (part.toolName === "getStockPrice") {
                    return <StockChart key={i} data={part.result} />;
                  }
                }
                return null;
              })}
              {message.role === "user" && (
                <p className="whitespace-pre-wrap">{message.content}</p>
              )}
            </div>
          </div>
        ))}
        {isLoading && (
          <div className="flex gap-3 justify-start">
            <div className="bg-slate-100 text-slate-900 rounded-2xl px-4 py-2 rounded-bl-none">
              <div className="flex gap-1">
                <span className="animate-bounce">●</span>
                <span className="animate-bounce" style={{ animationDelay: "0.1s" }}>●</span>
                <span className="animate-bounce" style={{ animationDelay: "0.2s" }}>●</span>
              </div>
            </div>
          </div>
        )}
      </div>
      <form onSubmit={handleSubmit} className="border-t border-slate-200 p-4">
        <div className="flex gap-2">
          <input
            value={input}
            onChange={handleInputChange}
            placeholder="Ask about weather or stock prices..."
            className="flex-1 rounded-lg border border-slate-300 bg-white px-4 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
            disabled={isLoading}
          />
          <button
            type="submit"
            disabled={isLoading || !input.trim()}
            className="rounded-lg bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Send
          </button>
        </div>
      </form>
    </div>
  );
}

Key points:

  • message.parts contains the streamed segments — text and tool results interleaved
  • Tool results arrive as { type: "tool-result", toolName, result } objects
  • The component renders the appropriate UI component for each tool result

Wire it into the page

// app/page.tsx
import { Chat } from "@/app/components/Chat";

export default function Home() {
  return (
    <main className="min-h-screen bg-slate-50 p-8">
      <div className="max-w-3xl mx-auto">
        <header className="mb-8 text-center">
          <h1 className="text-3xl font-bold text-slate-900">Generative UI Demo</h1>
          <p className="mt-2 text-slate-600">
            Ask about weather or stock prices. The assistant renders live components.
          </p>
        </header>
        <Chat />
      </div>
    </main>
  );
}

Run and verify

npm run dev

Open http://localhost:3000. Try these prompts:

  • “What’s the weather in Seattle?”
  • “Show me AAPL and TSLA stock prices”
  • “Compare weather in Miami and San Francisco”

You should see the assistant respond with text, then the tool calls execute, and the WeatherCard or StockChart components render inline within the message bubble — streamed as they complete.

How the streaming works

  1. Client sends messages to /api/chat
  2. streamText calls the model with tools defined
  3. Model emits a tool call → streamText executes the tool on the server
  4. Tool result streams back as a tool-result part
  5. Client receives the part via the data stream and renders the mapped component
  6. Model continues generating text with the tool result in context

The toDataStreamResponse() format is a newline-delimited JSON stream. Each line is a protocol message: text-delta, tool-call, tool-result, finish, etc. The useChat hook parses this automatically.

Handling multiple tool calls in parallel

The model may emit multiple tool calls in one turn. streamText executes them concurrently. Results stream back as they resolve — no waiting for the slowest call.

// In actions.ts, simulate variable latency
execute: async ({ location }) => {
  await new Promise((r) => setTimeout(r, Math.random() * 1000 + 500));
  return { ... };
}

The UI renders each card the moment its result arrives. This is the key advantage of streaming generative UI over blocking on a full response.

Error handling

Tools can throw. Wrap execution and return structured errors the model can reason about.

export const getWeather = createTool({
  // ...
  execute: async ({ location }) => {
    try {
      const response = await fetch(`https://api.weather.com/...`);
      if (!response.ok) throw new Error("Weather API error");
      return response.json();
    } catch (error) {
      return { error: "Failed to fetch weather", location };
    }
  },
});

The component receives the error object and can render a fallback:

if (part.type === "tool-result" && part.result.error) {
  return (
    <div className="rounded-lg bg-red-50 p-3 text-red-700 text-sm">
      Could not load {part.toolName}: {part.result.error}
    </div>
  );
}

Adding a third tool: user location

Server actions can access headers, cookies, and request context. Use this for personalization.

// app/actions.ts
import { headers } from "next/headers";

export const getLocalWeather = createTool({
  parameters: z.object({}),
  execute: async () => {
    const headersList = await headers();
    const forwarded = headersList.get("x-forwarded-for");
    const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
    // In production, use a geolocation service
    return getWeather.execute({ location: "San Francisco, CA" });
  },
});

Add it to the route’s tools object. The model now has a zero-argument tool for “weather here.”

Production considerations

  • Rate limiting: Wrap the route with middleware or use Upstash Redis for per-IP limits
  • Authentication: Check req.headers.get("authorization") in the route before calling streamText
  • Observability: Log tool calls, latency, and token usage. The AI SDK exposes result.usage after the stream finishes
  • Caching: Weather and stock data are cacheable. Use next: false in the tool definition if you need fresh data every time, or implement your own caching layer
  • Provider fallback: If you route through a gateway like n4n.ai, configure automatic fallback when a provider is degraded — the route code stays the same

Extending the pattern

This architecture scales to complex UIs:

Tool Component Use case
searchProducts ProductGrid E-commerce recommendations
queryDatabase DataTable Analytics dashboards
generateChart ChartJS / Recharts Dynamic visualizations
createCalendarEvent CalendarPreview Scheduling assistants

Each tool returns typed data. Each component renders that type. The model decides which to call based on user intent.

Summary

You now have a working generative UI system:

  • Tools defined with Zod schemas and server-side execution
  • React Server Components that render tool results
  • A streaming API route that interleaves text and component data
  • A client hook that assembles the stream into a conversation

The pattern is minimal — no custom protocol, no WebSocket server, no client-side state machine. The AI SDK handles the streaming protocol; React handles the rendering. Your code defines the tools and their UI.

Tagsnext-jsapp-routergenerative-uivercel-ai-sdk

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk generative ui with react server components posts →