【次世代UI】Vercel AI SDKで実現する「Generative UI」入門!AIがReactコンポーネントを動的生成・レンダリングするモダンフロントエンド構築ガイド

AIとユーザーの対話インターフェースは、大きな転換期を迎えています。これまでの「テキストやMarkdownでの単純な応答」から、ユーザーの意図に合わせて「最適な操作画面(UIコンポーネント)をその場で動的に生成・表示する」というアプローチへの進化です。これを実現するのが「Generative UI」です。

本記事では、フロントエンドエンジニアに向けて、Vercel AI SDKとNext.js(App Router)を組み合わせ、LLMの呼び出し結果からReactコンポーネントをシームレスに動的レンダリングする実装方法を解説します。LLMが生成した構造化データをもとに、クライアント側でリッチなインタラクティブUIを表示する手順をマスターしましょう。


1. Generative UIとは?そのパラダイムシフトとメリット

従来のチャットボットでは、AIからの返答はテキスト形式(Markdown)が主流でした。しかし、「ホテルの予約情報を確認する」「株価チャートを比較する」「フライトを予約する」といった複雑なタスクでは、テキストよりもカードやグラフ、ボタンなどの「インタラクティブなUI」の方が圧倒的に優れたユーザー体験(UX)を提供できます。

Generative UIとは、AI(LLM)が自律的に必要な「Tool(関数)」を選択し、その処理結果に最適なコンポーネントを動的にマッピングしてユーザーにレンダリングする仕組みです。

Generative UIのメリット

  • 直感的な操作性: 単なるテキストの羅列ではなく、ボタンやフォームなど操作可能なUIが直接表示されるため、次のアクションに迷いません。
  • セキュリティと型安全性: AIがHTMLを直接生成して差し込む(危険な dangerouslySetInnerHTML など)のではなく、定義済みのReactコンポーネントにAIが生成した型安全なデータを流し込むため、安全で崩れないUIを提供できます。
  • 高度なコンポーネント状態の共有: Next.jsのServer ComponentsとClient Componentsの強力な連携により、サーバー側でLLMを実行し、クライアント側でリッチな状態管理を引き継ぐことができます。

実務レベルのアプリ開発におけるNext.jsとAIの連携については、以前に解説したCursor・Vercel・Supabaseで爆速ビルドするAIアプリ構築ガイドも非常に相性が良いアプローチなので、併せて参考にしてください。


2. Vercel AI SDKによるGenerative UIのアーキテクチャ

Vercel AI SDKは、さまざまなLLM(OpenAI, Anthropic, Gemini等)とフロントエンドを繋ぐための最も洗練されたフレームワークです。Vercel AI SDK GitHubリポジトリで活発に開発が進められており、特に ai パッケージに含まれる構造化出力(streamObjecttool 呼び出し)の仕組みがGenerative UIの基盤となります。

コンポーネント生成の流れ

  1. ユーザーの入力: ユーザーが「昨日の株価と現在の為替レートを見せて」と入力する。
  2. LLMによるツール判定: LLM(例: gpt-4o)が入力内容を解析し、定義された「Tool(関数)」を実行すべきと判断する。
  3. データフェッチとUIマッピング: サーバー側で実際のAPIから株価データを取得し、対応するReactコンポーネント(<StockCard />)にPropsとして渡す。
  4. ストリーミング描画: 生成されたUIがストリーミング形式でユーザーのブラウザに直接レンダリングされる。

3. 実践!Next.jsでのGenerative UI実装手順

ここからは、実際にNext.js(App Router)を使って、AIが「天気情報カード」を動的に生成して画面に描画するシンプルなGenerative UIアプリを構築してみましょう。

3.1. パッケージのインストール

まずは、必要なライブラリをNext.jsプロジェクトにインストールします。

npm install ai @ai-sdk/openai zod lucide-react

3.2. 動的コンポーネントの作成

LLMが呼び出す「天気カード」コンポーネントを作成します。これはデザインが整った純粋なReactコンポーネントです。

// components/weather-card.tsx
import { Sun, Cloud, CloudRain } from 'lucide-react';

type WeatherProps = {
  location: string;
  temperature: number;
  condition: 'sunny' | 'cloudy' | 'rainy';
  forecast: string;
};

export function WeatherCard({ location, temperature, condition, forecast }: WeatherProps) {
  const getIcon = () => {
    switch (condition) {
      case 'sunny': return <Sun className="h-8 w-8 text-yellow-500" />;
      case 'cloudy': return <Cloud className="h-8 w-8 text-gray-400" />;
      case 'rainy': return <CloudRain className="h-8 w-8 text-blue-500" />;
    }
  };

  return (
    <div className="p-4 border rounded-2xl bg-slate-50 shadow-md max-w-sm mt-2">
      <div className="flex items-center justify-between">
        <div>
          <h3 className="text-lg font-bold text-slate-800">{location}</h3>
          <p className="text-sm text-slate-500">現在の天気</p>
        </div>
        {getIcon()}
      </div>
      <div className="mt-4">
        <span className="text-3xl font-extrabold text-slate-900">{temperature}°C</span>
      </div>
      <p className="mt-2 text-sm text-slate-600">{forecast}</p>
    </div>
  );
}

3.3. APIルート (Server Action) の実装

Next.jsのServer Actions(あるいはRoute Handlers)を利用して、Vercel AI SDKの streamUI (または streamText + Toolのクライアントマッピング)を実行します。ここでは最新のモダンなアプローチである streamUI を用いたサーバー定義の手法を実装します。

// app/actions.tsx
'use server';

import { createAI, streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { ReactNode } from 'react';
import { WeatherCard } from '@/components/weather-card';

export interface Message {
  id: string;
  role: 'user' | 'assistant';
  display: ReactNode;
}

export async function submitUserMessage(userInput: string): Promise<Message> {
  'use server';

  const ui = await streamUI({
    model: openai('gpt-4o-mini'),
    prompt: userInput,
    text: ({ content }) => <p className="text-slate-700">{content}</p>,
    tools: {
      showWeather: {
        description: '特定の場所の天気を表示するツール。ユーザーが場所を指定した際に呼び出してください。',
        parameters: z.object({
          location: z.string().describe('都市名や場所の名前'),
          temperature: z.number().describe('現在の摂氏温度'),
          condition: z.enum(['sunny', 'cloudy', 'rainy']).describe('天候の状態'),
          forecast: z.string().describe('短い天気概況(例: 午後から雨が降る見込みです)')
        }),
        generate: async function* ({ location, temperature, condition, forecast }) {
          // 擬似ローディングをはさむ
          yield <div className="animate-pulse p-4 border rounded-2xl bg-slate-100 max-w-sm">データを取得中...</div>;
          
          // コンポーネントを返す
          return (
            <WeatherCard
              location={location}
              temperature={temperature}
              condition={condition}
              forecast={forecast}
            />
          );
        }
      }
    }
  });

  return {
    id: Date.now().toString(),
    role: 'assistant',
    display: ui.value
  };
}

export const AI = createAI<any, Message[]>({ 
  actions: { submitUserMessage },
  initialUIState: [],
  initialAIState: []
});

3.4. クライアントサイドのUIの実装

最後に、ユーザーがチャットを入力し、サーバーアクションを呼び出して動的UIを受け取るクライアントコンポーネントを作成します。

// app/page.tsx
'use client';

import { useState } from 'react';
import { useActions, useUIState } from 'ai/rsc';
import { AI } from './actions';

export default function ChatPage() {
  const [input, setInput] = useState('');
  const [conversation, setConversation] = useUIState<typeof AI>();
  const { submitUserMessage } = useActions<typeof AI>();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;

    // ユーザーの発言をUIに追加
    setConversation((current) => [
      ...current,
      { id: Date.now().toString(), role: 'user', display: <p className="font-semibold">{input}</p> }
    ]);

    setInput('');

    // サーバーサイドからのGenerative UIをストリーミング受信
    const response = await submitUserMessage(input);
    setConversation((current) => [...current, response]);
  };

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <header className="border-b pb-4 mb-4">
        <h1 className="text-xl font-bold">Generative UI デモ</h1>
      </header>

      <main className="flex-1 overflow-y-auto space-y-4 mb-4">
        {conversation.map((message) => (
          <div key={message.id} className={`p-2 rounded-lg ${message.role === 'user' ? 'text-right' : 'text-left'}`}>
            {message.display}
          </div>
        ))}
      </main>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          placeholder="東京の天気を教えて、と聞いてみてください..."
          value={input}
          onChange={(e) => setInput(e.target.value)}
        />
        <button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-2 rounded-lg transition-colors">
          送信
        </button>
      </form>
    </div>
  );
}

※注意: アプリケーションのルート全体を app/layout.tsx<AI> プロバイダーでラップするのを忘れないでください。


4. トラブルシューティング:開発者が直面しやすい落とし穴と解決策

Generative UIを実装する際、特にReact Server Components (RSC) とServer Actionsの境界をまたぐため、特有のエラーに遭遇することがよくあります。

エラー例:「Only plain objects, and a few built-ins, can be passed to Client Components from Server Actions.」

原因

このエラーは、Server Actions(サーバー側)からクライアント側に渡そうとしたデータの中に、**シリアライズ(直列化)できないデータ(Reactコンポーネントそのものや関数、シンボルなど)**が含まれている場合に発生します。ai/rsc を用いた古い streamUI では内部的にコンポーネントをうまくバイパスするラッパーが用意されていますが、複雑な状態管理や非同期処理の連携において、誤って複雑なオブジェクトをそのままClient ComponentsへPropsとして渡してしまうとこのエラーが誘発されます。

解決方法

  1. データのシリアライズを保証する: コンポーネントを直接サーバー側でJSXとして生成して渡すのではなく、LLMからのレスポンスデータ(Zodスキーマに合致したプレーンなJSONオブジェクト)のみをクライアントに渡し、クライアント側でそのデータを検知してReactコンポーネントにマッピングする設計に変更します(「データ・ドリブン・レンダリング」アプローチ)。
  2. 最新のVercel AI SDK Core APIへの移行: 現在、Vercel AI SDKは ai/rsc から、より軽量かつ予測可能な ai パッケージの streamObjectuseChattoolCall を活用するアプローチを推奨しています。クライアントサイドで以下のようにツール実行結果を受け取り、コンポーネントを出し分ける方がエラーが発生しにくくデバッグが容易です。
// クライアントサイドでのTool呼び出し結果ハンドリング例
const { messages } = useChat({
  api: '/api/chat',
});

// メッセージレンダリング部分
{messages.map(m => {
  if (m.toolCalls) {
    return m.toolCalls.map(toolCall => {
      if (toolCall.toolName === 'showWeather') {
        // toolCall.args にスキーマ通りの安全なプレーンオブジェクトが入る
        return <WeatherCard key={toolCall.toolCallId} {...toolCall.args} />;
      }
    });
  }
  return <p>{m.content}</p>;
})}

このように設計を分離することで、Next.jsのバージョンアップに伴うシリアライズバグに頭を悩まされることがなくなります。


5. まとめとさらなるステップ

Vercel AI SDKがもたらすGenerative UIは、従来の静的な対話チャットに革命をもたらし、ユーザーに最適なインターフェースをリアルタイムで構築することを可能にしました。デザインシステム(Shadcn UIやTailwind CSSなど)と適切にマッピングさせることで、一貫性のある高品質なデザインのまま、動的な対話型Webサービスを実現できます。

より低遅延なボイスインターフェースとNext.jsの融合に興味がある方は、OpenAI Realtime APIとNext.jsで構築するWebRTC音声アプリ開発ガイドもご覧ください。次世代のAIネイティブなUI/UXへの理解がさらに深まるはずです。

最先端のAIフロントエンド開発へ、ぜひ一歩を踏み出してみましょう!