Building Generative UIs with Next.js 15 App Router and the Vercel AI SDK
Learn how to build real-time generative interfaces with Next.js 15 App Router, React 19, and the Vercel AI SDK. This guide covers streaming LLM responses, Server Components, optimistic updates, and Server Actions for interactive tool components.
Key Takeaway / TL;DR:
- Next.js 15 App Router and React 19 enable seamless integration of streaming LLM responses with Server Components, reducing client-side complexity.
- The Vercel AI SDK provides
useChatandstreamTextfor efficient token-by-token streaming, while Server Actions allow secure tool invocations.- Optimistic updates and interactive tool components enhance user experience, but require careful handling of loading states and error boundaries.
The Problem & Industry Shift
Traditional web applications are static or fetch data on demand. With the rise of generative AI, users expect real-time, interactive experiences—like chatbots that stream responses token by token, or UIs that update as the model reasons. However, building these with classic client-side rendering leads to performance bottlenecks, complex state management, and poor SEO.
Next.js 15 App Router and React 19 introduce a paradigm shift: Server Components allow rendering logic to run on the server, reducing client-side JavaScript. Combined with the Vercel AI SDK, developers can stream LLM responses directly into the component tree, enabling generative UIs that are fast, SEO-friendly, and maintainable.
Architecture & Core Mechanics
The core architecture involves three layers:
- Server Components: Fetch initial data and render static parts of the UI. They can also render streaming content via
streamTextandStreamingTextResponse. - Client Components: Handle interactive elements like chat input, tool buttons, and optimistic updates. They use
useChatfrom the AI SDK to manage the chat state. - Server Actions: Allow client components to invoke server-side functions securely, such as calling external APIs or performing database operations.
Here's a high-level data flow:
[Client] --useChat--> [API Route] --streamText--> [LLM] --> [Streaming Response] --> [Client]
| ^
|--Server Action (tool call)--> [Server] --> [Result] --> [Client] |
|__________________________________________________________________________|
The AI SDK's streamText function streams tokens from the LLM, and the useChat hook on the client consumes them, updating the UI in real time. Tool calls are handled via Server Actions, which can be invoked from the client and their results fed back into the chat.
Production Code Example
Let's implement a simple generative UI that streams a response and includes a tool component for fetching weather data.
1. API Route for Streaming
Create app/api/chat/route.ts:
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant. Use the weather tool when asked about weather.',
messages,
tools: {
getWeather: {
description: 'Get the current weather in a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
execute: async ({ city }) => {
// In production, call a real weather API
const weather = { city, temperature: 72, condition: 'Sunny' };
return weather;
},
},
},
});
return result.toAIStreamResponse();
}
2. Client Component with useChat
Create app/components/chat.tsx:
'use client';
import { useChat } from 'ai/react';
import { useState } from 'react';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
const [optimisticCity, setOptimisticCity] = useState<string | null>(null);
const handleSubmitWithOptimistic = (e: React.FormEvent) => {
e.preventDefault();
// Optimistically show a loading state for the tool call
if (input.toLowerCase().includes('weather')) {
setOptimisticCity('Fetching weather...');
}
handleSubmit(e);
};
return (
<div>
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
{/* Render tool results if present */}
{m.toolInvocations?.map((inv) => (
<div key={inv.toolCallId}>
{inv.toolName === 'getWeather' && (
<WeatherCard data={inv.result} />
)}
</div>
))}
</div>
))}
{optimisticCity && <div>{optimisticCity}</div>}
</div>
<form onSubmit={handleSubmitWithOptimistic}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about weather..."
/>
<button type="submit" disabled={isLoading}>
Send
</button>
</form>
</div>
);
}
function WeatherCard({ data }: { data: any }) {
return (
<div>
<h3>Weather in {data.city}</h3>
<p>{data.temperature}°F, {data.condition}</p>
</div>
);
}
3. Server Component Integration
In app/page.tsx, we can use a Server Component to pre-render the initial chat interface:
import { Chat } from './components/chat';
export default function Page() {
return (
<main>
<h1>Generative UI with Next.js 15</h1>
<Chat />
</main>
);
}
This setup streams LLM responses directly to the client, and tool calls are executed on the server via the execute function, with results streamed back.
Performance, Cost & Trade-offs
- Streaming vs. Non-streaming: Streaming improves perceived performance (TTFB) but increases complexity. Use
streamTextfor token-by-token updates. - Server Components: Reduce client-side JS, improving load times and SEO. However, they require careful design to avoid excessive server round-trips.
- Tool Calls: Executing tools on the server adds latency but ensures security (no API keys exposed). Consider caching tool results to reduce costs.
- Cost: LLM API costs can escalate with streaming. Implement rate limiting and caching strategies.
- Security: Never expose API keys to the client. Use Server Actions for any sensitive operations.
Actionable Checklist / Summary
When adopting generative UI in production:
- Use Server Components for static parts and initial data fetching.
- Leverage
useChatfor chat state and streaming. - Implement tools via Server Actions to keep secrets secure.
- Add optimistic updates for better UX, but always reconcile with server state.
- Handle errors gracefully with error boundaries and fallback UI.
- Monitor performance with tools like Vercel Analytics.
- Test thoroughly with mock LLM responses and edge cases.
By following these practices, you can build fast, interactive generative UIs that scale.