Chapter 3: Vercel AI SDK Core - The 'AI' Protocol
Theoretical Foundations
The foundational shift introduced by the Vercel AI SDK’s "AI" Protocol is the reimagining of the client-server boundary in generative applications. Traditionally, web development has treated the server as a stateless calculator and the client as a stateful UI manager. In the context of AI, this created a fragmentation: the server generated a stream of text tokens, and the client had to interpret these tokens to reconstruct a UI, often resulting in brittle parsing logic and a disconnected user experience.
The AI Protocol solves this by establishing a unified streaming architecture where the server is not just a data provider, but a UI orchestrator. It treats the generation of an interface—whether that is a string of text, a structured data object, or a fully interactive React component—as a first-class streamable entity.
To understand this deeply, we must look back at Book 1: The RAG Pipeline. In that section, we discussed K-Nearest Neighbors (KNN). KNN is a retrieval algorithm that finds the most similar vectors to a query. While KNN is purely mathematical, its output is the input for the AI Protocol. The AI Protocol takes the retrieved context (from KNN) and transforms it not just into a response, but into a visual representation of that response, streamed in real-time.
The Analogy: The Restaurant Kitchen vs. The Food Truck
Imagine a traditional web application as a sit-down restaurant. 1. The Order: You (the client) send a request to the kitchen (the server). 2. The Preparation: The kitchen prepares the entire meal (the AI generates the entire response). 3. The Delivery: Only when the meal is fully plated does the waiter (the network) bring it to your table. 4. The Experience: You wait in silence until the plate arrives. If the meal takes 5 minutes, you stare at the wall for 5 minutes.
Now, imagine the AI Protocol as a high-end food truck with an open kitchen. 1. The Order: You place an order. 2. The Streaming Preparation: The chef starts cooking immediately. You can see the onions sizzling (the first text tokens appear). Then, the chef assembles the taco shell (a UI component structure). As the ingredients are added (more tokens), the taco is handed to you piece by piece, or the entire dish is assembled in front of you. 3. The Experience: You are engaged in the process. You receive value incrementally. If the chef decides to add a garnish (a dynamic UI element like a button or a chart) based on the freshness of the ingredients (the context retrieved by KNN), you see it happen in real-time.
The AI Protocol allows the server to hand over "ingredients" (tokens) and "pre-assembled dishes" (React components) through the same delivery window (the stream), eliminating the need for the client to cook the meal itself.
The Architecture: RSC as the Transport Layer
The genius of the AI Protocol is that it leverages React Server Components (RSC) not just as a rendering strategy, but as a data transport protocol. In a standard API route, you send JSON. In RSC, you send a serialized React tree.
When we use streamUI (the server-side function), we are instructing the server to traverse the React component tree and stream the HTML-like markup (and the JavaScript instructions to make it interactive) to the client.
The "Why" of RSC Transport:
* Bandwidth Efficiency: Sending a pre-built React component is often smaller than sending raw data plus the JavaScript code required to build that component on the client.
* Security: The logic for fetching data (e.g., via KNN) stays on the server. The client never sees the raw vector database or the API keys for the AI model.
* Atomicity: The server can decide to render a <Chart /> component or a <Text /> component based on the AI's reasoning, and the client receives it as a finished unit.
The Mechanism: streamUI and Token-Level Control
The streamUI function is the heart of the protocol. It is an asynchronous generator that yields "UI updates" rather than just text.
Let's break down the lifecycle of a stream using the KNN context:
- Input: A user asks, "Show me the sales trend for Q3."
- Retrieval: The system uses KNN to find the top 3 relevant documents from the vector database (e.g., Q3 sales reports).
- Generation & Rendering: The LLM receives the query and the KNN results. As it generates,
streamUIintercepts the token stream.- Token 1-10 ("Here is the"): The server streams a standard text fragment.
- Token 11-20 ("chart"): The LLM decides a visual representation is needed.
streamUIpauses text streaming and begins streaming a serialized<BarChart />component. - Token 21-30 ("click to drill down"): The LLM adds interactivity. The server streams the component with an
onClickhandler attached.
The client does not need to know how to build a chart. It simply receives the instruction to render the chart component.
The Data Flow: A Visual Representation
The following diagram illustrates how the AI Protocol bridges the gap between the LLM's token generation and the client's UI state, bypassing the traditional REST/JSON intermediary.
The useAI Hook: Managing the Streamed State
While streamUI handles the server-side generation, the useAI hook (and its sibling useChat) acts as the client-side consumer of the AI Protocol.
In a traditional application, if you stream text, you might use a simple useState to append characters. However, because the AI Protocol streams mixed content (text and components), the state management becomes significantly more complex.
The "Why" of useAI:
It abstracts the complexity of merging disparate data types into a coherent message history.
Consider the stream:
1. Text: "I found a relevant document."
2. Component: <SourceCard title="Q3 Report" />
3. Text: "Here is the summary."
The useAI hook maintains a messages array. It doesn't just store strings; it stores a React Node or a reference to a component that can be rendered. When the stream arrives, useAI intelligently merges these chunks.
Under the Hood: The Merge Algorithm
When the client receives a chunk from the RSC stream, useAI performs a reconciliation similar to React's own diffing algorithm, but optimized for streaming:
1. Accumulation: It holds the current "message" being streamed.
2. Type Checking: It checks if the incoming chunk is a string or a component reference.
3. State Update: It updates the state, triggering a re-render of the UI.
The Web Development Analogy: Embeddings as Hash Maps
To solidify the theoretical foundation, let's draw an analogy between Embeddings (from Book 1) and Hash Maps (a standard CS data structure).
- Hash Map: Takes a key, runs it through a hash function, and outputs an index in an array. It allows for O(1) lookup time.
- Embedding: Takes a piece of text (the key), runs it through a neural network, and outputs a vector of floating-point numbers (the index in high-dimensional space).
In the context of the AI Protocol, the KNN algorithm is essentially performing a similarity search over a distributed Hash Map. * The Query: You are looking for a value. * The Vector Index: This is the Hash Map, but instead of exact key matching, it matches based on "semantic distance" (Euclidean or Cosine distance).
When we use the AI Protocol, we are effectively saying: "Look up the value in this semantic Hash Map (via KNN), and instead of returning the raw value, render it using this component (via streamUI)."
Summary of the Protocol
The AI Protocol is a paradigm shift from Request-Response to Request-Stream-Render.
- Server-Side:
streamUIacts as a render engine that runs on the server. It consumes tokens from an LLM and outputs a stream of RSC payloads. - Transport: The stream is transmitted via HTTP/2 or WebSocket. It carries a hybrid payload: raw text and serialized React components.
- Client-Side: The
useAIhook receives this stream, deserializes the RSC payload, and updates the local state, causing React to render the new UI.
This architecture removes the "client-side tax"—the cost of parsing JSON and building UIs from data on the browser—and moves it to the server where resources are abundant, resulting in a faster, more responsive, and more secure generative UI experience.
Basic Code Example
This example demonstrates a minimal, self-contained SaaS-style web application that streams a generative UI component directly from a React Server Component (RSC) using the Vercel AI SDK. We will simulate a "Chat with AI" interface where the server generates a dynamic UI (e.g., a "Hello World" card) in response to a user prompt. The core mechanism is the streamUI function, which allows the server to render React components incrementally over Server-Sent Events (SSE), ensuring immutable state management and efficient streaming without client-side API routes.
The application consists of:
1. A Server Component (Page.tsx) that handles the AI generation and streaming.
2. A Client Component (ChatInterface.tsx) that displays the streamed UI and handles user input.
3. A Mock AI Provider to simulate the LLM response (in a real app, this would be OpenAI, Anthropic, etc.).
This setup emphasizes the "AI Protocol" by treating the UI itself as a streamable data structure, leveraging TypeScript interfaces for type safety and RSC for server-side execution.
// File: app/page.tsx
// This is a React Server Component (RSC) that runs exclusively on the server.
// It orchestrates the AI generation and streams the UI directly to the client.
'use server'; // Marks this file as a Server Component in Next.js App Router.
import { streamUI } from '@vercel/ai-sdk/rsc'; // Import the RSC-specific streamUI function.
import { generateId } from '@vercel/ai-sdk/core'; // Helper for unique IDs.
import { ChatInterface } from '@/components/ChatInterface'; // Client component for UI.
import { MockProvider } from '@/lib/mock-ai'; // Simulated AI provider (defined below).
/**
* @description A mock AI provider that simulates a streaming response.
* In production, this would be replaced with `openai.chat.completions.createStream`.
* @returns {AsyncIterable<{content: string}>} A stream of text chunks.
*/
const mockProvider = new MockProvider();
/**
* @description The main server action triggered by the client.
* @param {string} prompt - The user's input.
* @returns {Promise<React.ReactNode>} A streamable UI component.
*/
export async function generateUI(prompt: string) {
// 1. Define the UI component to be rendered by the AI.
// This is a function that returns a React element.
// The `content` prop will be streamed from the AI.
const component = ({ content }: { content: string }) => (
<div className="p-4 bg-blue-100 border border-blue-300 rounded-lg shadow-sm">
<h3 className="font-bold text-blue-800">Generated Response</h3>
<p className="text-blue-700 mt-2">{content}</p>
</div>
);
// 2. Call streamUI to generate and stream the component.
// This function handles the SSE connection under the hood.
const result = await streamUI({
model: 'gpt-3.5-turbo', // Placeholder model name.
prompt: `Generate a concise response to: "${prompt}". Keep it under 20 words.`,
// 3. The text stream callback: This is called incrementally as tokens arrive.
text: ({ content, done }) => {
// If done, we finalize; otherwise, we return the partial content.
// This is where immutable state updates happen on the server side.
if (done) {
return component({ content }); // Final render.
}
// Return a loading state or partial content while streaming.
return (
<div className="p-4 bg-gray-100 border border-gray-300 rounded-lg">
<p className="text-gray-500">Thinking... {content}</p>
</div>
);
},
// 4. Optional: Define tools (not used in this basic example).
tools: {},
});
return result;
}
// 5. The Page Component that renders the Client Interface.
export default function Page() {
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-2xl font-bold mb-4">Generative UI Streaming</h1>
<ChatInterface generateUI={generateUI} />
</main>
);
}
// File: components/ChatInterface.tsx
// This is a Client Component (runs in the browser).
// It handles user input and displays the streamed UI from the server.
'use client';
import { useState } from 'react';
import { experimental_useAI as useAI } from '@vercel/ai-sdk/react'; // Use the useAI hook for state management.
// Define the TypeScript interface for the props.
interface ChatInterfaceProps {
generateUI: (prompt: string) => Promise<React.ReactNode>;
}
export function ChatInterface({ generateUI }: ChatInterfaceProps) {
// 1. State management for the user's input.
const [input, setInput] = useState('');
// 2. Use the `useAI` hook to manage the streaming state.
// `useAI` handles the SSE connection, message parsing, and UI updates.
// It returns the current UI (as a React node) and a function to submit.
const { messages, submit, isLoading } = useAI({
api: generateUI, // The server action from page.tsx.
initialMessages: [], // Start with an empty conversation.
});
// 3. Handle form submission.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
// Submit the prompt to the server action.
// The hook automatically manages the streaming and updates `messages`.
await submit(input);
setInput(''); // Clear input after submission.
};
return (
<div className="max-w-2xl mx-auto space-y-4">
{/* 4. Render the streamed UI messages. */}
<div className="space-y-4">
{messages.map((msg, index) => (
<div key={index} className="animate-fade-in">
{/* The `content` is the streamed React component from the server. */}
{msg.content}
</div>
))}
</div>
{/* 5. Input Form */}
<form onSubmit={handleSubmit} className="flex gap-2 mt-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask something..."
className="flex-1 p-2 border rounded-md"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50"
>
{isLoading ? 'Streaming...' : 'Send'}
</button>
</form>
</div>
);
}
// File: lib/mock-ai.ts
// A simple mock implementation to simulate an AI provider.
// This avoids external dependencies for the "Hello World" example.
export class MockProvider {
/**
* @description Simulates a streaming response by yielding chunks of text.
* @returns {AsyncIterable<{content: string}>}
*/
async *createStream(prompt: string): AsyncIterable<{ content: string }> {
const response = `This is a generated response to: "${prompt}". It demonstrates streaming UI components.`;
// Simulate network latency and token-by-token streaming.
const words = response.split(' ');
for (const word of words) {
await new Promise(resolve => setTimeout(resolve, 100)); // Delay for realism.
yield { content: word + ' ' };
}
}
}
Line-by-Line Explanation
1. Server Component (app/page.tsx)
'use server';: This directive marks the file as a React Server Component. Code here executes on the server, reducing bundle size and enabling direct database/API access. It allows the use ofstreamUIwithout exposing API keys.import { streamUI } from '@vercel/ai-sdk/rsc';: Imports the core function for streaming UI. The/rscentry point is optimized for server-side execution, handling the serialization of React elements into the SSE stream.import { generateId } from '@vercel/ai-sdk/core';: A utility for generating unique message IDs, crucial for immutable state tracking in chat logs.const mockProvider = new MockProvider();: Instantiates a mock AI provider. In production, you would useopenai.chat.completions.createStreamor similar. This abstraction allows the SDK to work with any provider that supports streaming.export async function generateUI(prompt: string): Defines a server action. This is an asynchronous function that can be called directly from the client (viauseAI). It returns aPromise<React.ReactNode>, which is the streamed UI.const component = ({ content }: { content: string }) => (...): Defines the React component template. Thecontentprop is dynamic and will be filled by the AI stream. This is immutable: each stream update creates a new component instance.await streamUI({ ... }): The heart of the example. It initiates the AI call and manages the streaming lifecycle.model: 'gpt-3.5-turbo': A placeholder. The SDK uses this to route to the correct provider.prompt: ...: The input to the AI. Here, we prepend instructions to ensure a concise response.text: ({ content, done }) => { ... }: A callback function invoked for each token (or chunk) from the AI.if (done): When the stream finishes, we render the final component with the complete content. This is an immutable update: a new component is returned.return <div>Thinking... {content}</div>: While streaming, we return a loading state. Thecontentis partial, allowing the client to show incremental progress.
tools: {}: Defines available tools (e.g., function calling). Omitted here for simplicity.return result;: ThestreamUIfunction returns aStreamableValuethat the client can consume via SSE.export default function Page(): The main layout component. It renders theChatInterfaceclient component, passing the server action as a prop.
2. Client Component (components/ChatInterface.tsx)
'use client';: Marks this as a Client Component, executed in the browser.import { experimental_useAI as useAI } from '@vercel/ai-sdk/react';: Imports theuseAIhook. Theexperimental_prefix indicates it's part of the SDK's evolving API, but it's stable for this use case. It abstracts SSE handling and state management.interface ChatInterfaceProps { generateUI: (prompt: string) => Promise<React.ReactNode>; }: Defines a TypeScript interface for type safety. This ensures thegenerateUIprop is a function returning a Promise of a React node.const [input, setInput] = useState('');: Standard React state for the input field. Immutable:setInputcreates a new state value without mutating the previous one.const { messages, submit, isLoading } = useAI({ ... }): The hook initializes the AI state.api: generateUI: Connects to the server action. The hook internally usesfetchwith streaming enabled.initialMessages: []: Sets up an empty array for immutable message history. Each new message is appended as a new object.messages: An array of message objects. Eachmsg.contentis the streamed React node from the server.submit(input): Triggers the server action. The hook manages the SSE connection, parsing the stream and updatingmessagesimmutably.isLoading: Boolean indicating if a stream is active.handleSubmit: Prevents default form behavior, checks for empty input, callssubmit, and resets the input state. This ensures predictable UI updates.{messages.map((msg, index) => ...)}: Renders each message. Thekey={index}is used for simplicity; in production, use a unique ID fromgenerateId. Theanimate-fade-inclass (not shown) adds a smooth entry animation.<input>and<button>: Standard form elements. The button is disabled during streaming to prevent duplicate submissions, enforcing atomic operations.
3. Mock Provider (lib/mock-ai.ts)
export class MockProvider: A simple class to simulate an AI provider. It decouples the example from external APIs.async *createStream(prompt: string): An async generator function. It yields chunks of text with delays to mimic real-time streaming. This is how the SDK expects data: an iterable of partial results.const words = response.split(' ');: Splits the response into words for incremental yielding.for (const word of words): Iterates over words, yielding each with a timeout. This simulates Server-Sent Events (SSE) where data arrives in chunks.
Common Pitfalls
-
Vercel Timeouts on Server Actions:
- Issue: Server actions have a default timeout (e.g., 10 seconds on Vercel's hobby plan). Long AI generations can fail.
- Solution: Use
streamUIto return partial results early. For very long streams, consider increasing the timeout invercel.jsonor using Edge functions. Always handle errors in thetextcallback withtry-catch.
-
Async/Await Loops in Streaming:
- Issue: Blocking the event loop with synchronous waits (e.g.,
while (!done)) can freeze the UI. In RSC, this can cause the entire page to hang. - Solution: Use async generators (as in
MockProvider) or the SDK's built-in streaming. Avoidawaitinside loops for streaming; instead, yield values incrementally. In thetextcallback, never perform heavy computations—keep it lightweight for UI rendering.
- Issue: Blocking the event loop with synchronous waits (e.g.,
-
Hallucinated JSON or Invalid React Elements:
- Issue: If the AI provider returns malformed JSON (e.g., when using tools),
streamUImay fail to parse it, causing runtime errors. - Solution: Validate the stream output. Use TypeScript interfaces (e.g.,
interface StreamResponse { content: string; }) to enforce structure. In thetextcallback, always check ifcontentis defined before rendering. For complex UIs, consider using a schema validator like Zod.
- Issue: If the AI provider returns malformed JSON (e.g., when using tools),
-
Immutable State Violations in Client Components:
- Issue: Directly mutating
messages(e.g.,messages.push(newMsg)) instead of usingsetMessages([...messages, newMsg])can lead to stale UI updates and lost streams. - Solution: The
useAIhook handles immutability internally, but if managing state manually, always create new arrays/objects. UseReact.memofor child components to prevent unnecessary re-renders during streaming.
- Issue: Directly mutating
-
SSE Connection Drops:
- Issue: Network interruptions can break the stream, leaving the client in a loading state.
- Solution: Implement retry logic in the client (e.g., via
useAI's built-in retry). On the server, ensurestreamUIhandles errors gracefully by returning a fallback component. Monitor Vercel logs for SSE-related errors (e.g., "Connection closed").
Visualization of the Streaming Flow
The following diagram illustrates the data flow in this example, emphasizing the server-client boundary and the immutable updates.
Explanation of Diagram:
1. Client to Server: The user submits a prompt, triggering the server action.
2. Server to AI: The server calls streamUI, which internally requests tokens from the AI provider.
3. AI to Server: The provider streams tokens back (simulated by MockProvider).
4. Server to Client: The server renders React components incrementally and sends them via SSE. Each token updates the UI immutably.
5. Client State Update: The useAI hook updates the local state with a new message object, ensuring no mutation of previous state. This flow avoids client-side API routes, centralizing logic on the server for security and efficiency.
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.