
Ai App
- 155 installs
- 57 repo stars
- Updated August 3, 2026
- laguagu/claude-code-nextjs-skills
Scaffold Next.js apps with AI features—chat UIs, streaming responses, tool use, and provider wiring—for production-ready intelligent web products.
About
ai-app from laguagu/claude-code-nextjs-skills guides Claude Code through constructing Next.js applications with integrated large-language-model features, including streaming chat, tool use, and sensible AI product architecture.
- Next.js AI app patterns
- Streaming LLM interfaces
- Provider and tool wiring
- Chat and agent UX scaffolds
- Production AI web stacks
Ai App by the numbers
- 155 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,323 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/laguagu/claude-code-nextjs-skills --skill ai-appAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 155 |
|---|---|
| repo stars | ★ 57 |
| Last updated | August 3, 2026 |
| Repository | laguagu/claude-code-nextjs-skills ↗ |
What it does
Scaffold Next.js apps with AI features—chat UIs, streaming responses, tool use, and provider wiring—for production-ready intelligent web products.
Files
AI App Generator
Build full-stack AI applications with Next.js, AI SDK, and ai-elements.
Quick Start
1. Scaffold Project
bunx --bun shadcn@latest create --preset "https://ui.shadcn.com/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=geist-sans&menuAccent=subtle&menuColor=default&radius=default" --template next my-ai-app
cd my-ai-app2. Install Dependencies
bun add ai @ai-sdk/react @ai-sdk/anthropic zod
bunx --bun ai-elements@latest3. Configure Environment
# .env.local - Choose your provider
ANTHROPIC_API_KEY=sk-ant-...
# OPENAI_API_KEY=sk-...
# GOOGLE_GENERATIVE_AI_API_KEY=...4. Generate Application
Based on user requirements, generate:
- Chatbot: See references/chatbot.md
- Agent Dashboard: See references/agent-dashboard.md
- Custom: Combine patterns as needed
Application Types
Chatbot
Simple conversational AI with streaming responses.
| Feature | Implementation |
|---|---|
| Chat UI | Conversation + Message + PromptInput |
| API | streamText + toUIMessageStreamResponse |
| Extras | Reasoning, Sources, File attachments |
Agent Dashboard
Multi-agent interface with tool visualization.
| Feature | Implementation |
|---|---|
| Agents | ToolLoopAgent with tools |
| UI | Dashboard layout + Tool components |
| API | createAgentUIStreamResponse |
| Extras | Status monitoring, tool approval |
Custom AI App
Mix and match based on user needs:
- Web search chatbot
- Code generation assistant
- Document analyzer
- Multi-modal chat
Project Structure
my-ai-app/
├── app/
│ ├── page.tsx # Main UI
│ ├── layout.tsx # Root layout
│ ├── globals.css # Theme
│ └── api/
│ └── chat/
│ └── route.ts # AI endpoint
├── components/
│ ├── ai-elements/ # AI Elements components
│ ├── ui/ # shadcn/ui components
│ └── chat.tsx # Chat component (if extracted)
├── lib/
│ ├── utils.ts # Utilities
│ └── ai.ts # AI configuration (optional)
├── ai/ # Agent definitions (if needed)
│ └── assistant.ts
└── .env.local # API keysSee references/project-structure.md for details.
Core Patterns
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages: await convertToModelMessages(messages),
system: 'You are a helpful assistant.',
});
return result.toUIMessageStreamResponse({
sendSources: true,
sendReasoning: true,
});
}Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import { Loader } from '@/components/ai-elements/loader';
import { useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text, files: message.files });
setInput('');
};
return (
<div className="flex h-screen flex-col p-4">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
</Message>
);
}
return null;
})}
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}Skill References
For detailed patterns, see:
| Need | Skill | Reference |
|---|---|---|
| Chat UI components | /ai-elements | chatbot.md |
| Next.js patterns | /nextjs-shadcn | architecture.md |
| AI SDK functions | /ai-sdk-6 | core-functions.md |
| Agents & tools | /ai-sdk-6 | agents.md |
| Caching | /cache-components | REFERENCE.md |
| Production patterns | /nextjs-chatbot | DB persistence, HITL approval, consent, feedback, search |
| Code review & cleanup | code-simplifier agent | DRY/KISS/YAGNI validation |
Workflow
Phase 1: Understand Requirements
Ask user:
- What type of AI app? (chatbot, agent, custom)
- What features? (reasoning, sources, tools, file upload)
- What style? (vega=classic, nova=compact, maia=soft/rounded, lyra=boxy/sharp, mira=dense) — default: nova
- What font? (geist-sans, inter, jetbrains-mono, figtree, dm-sans, outfit, noto-sans, nunito-sans, roboto, raleway, public-sans) — default: geist-sans
- What base color? (neutral, zinc, slate, gray, stone) — default: neutral
- What theme accent? (neutral, blue, green, orange, red, rose, violet) — default: neutral
- What border radius style? (default, sm, md, lg, xl)
- Component library? (radix=default, base-ui)
Phase 2: Scaffold Project
Run scaffolding commands based on requirements.
Phase 3: Generate Files
Create files based on application type:
- API route (
app/api/chat/route.ts) - Main page (
app/page.tsx) - Components (if needed)
- Agents (if needed)
Phase 4: Configure
- Set up
.env.local - Configure
next.config.tsif needed - Add any additional dependencies
Phase 5: Verify
bun devTest the application works correctly.
References
- Chatbot Templates - Full chatbot implementation
- Agent Dashboard Templates - Agent-based apps
- Project Structure - Directory layout
- Examples - Copy-paste examples
Package Manager
Always use bun, never npm:
bun add(not npm install)bunx --bun(not npx)bun dev(not npm run dev)
Agent Dashboard Templates
Templates for building agent-based AI applications with tool visualization.
Basic Agent Setup
Agent Definition
// ai/assistant.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const assistantAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: `You are a helpful AI assistant with access to various tools.
Use tools when needed to provide accurate information.
Always explain what you're doing before using a tool.`,
tools: {
searchWeb: tool({
description: 'Search the web for information',
inputSchema: z.object({
query: z.string().describe('Search query'),
}),
execute: async ({ query }) => {
// Implement web search
return { results: [`Result for: ${query}`] };
},
}),
calculateMath: tool({
description: 'Perform mathematical calculations',
inputSchema: z.object({
expression: z.string().describe('Math expression to evaluate'),
}),
execute: async ({ expression }) => {
// Use mathjs for safe expression evaluation
const { evaluate } = await import('mathjs');
try {
const result = evaluate(expression);
return { result: String(result) };
} catch {
return { error: 'Invalid expression' };
}
},
}),
getCurrentTime: tool({
description: 'Get the current date and time',
inputSchema: z.object({}),
execute: async () => {
return { time: new Date().toISOString() };
},
}),
},
stopWhen: stepCountIs(20),
});API Route
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from 'ai';
import { assistantAgent } from '@/ai/assistant';
export const maxDuration = 30;
export async function POST(request: Request) {
const { messages } = await request.json();
return createAgentUIStreamResponse({
agent: assistantAgent,
uiMessages: messages,
sendSources: true,
includeUsage: true,
});
}---
Dashboard Page with Tool Visualization
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from '@/components/ai-elements/reasoning';
import { Loader } from '@/components/ai-elements/loader';
import { useState } from 'react';
export default function AgentDashboard() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput('');
};
return (
<div className="flex h-screen">
{/* Sidebar */}
<aside className="w-64 border-r bg-muted/50 p-4">
<h2 className="mb-4 font-semibold">AI Assistant</h2>
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
<p>Available tools:</p>
<ul className="ml-4 list-disc">
<li>Web Search</li>
<li>Calculator</li>
<li>Current Time</li>
</ul>
</div>
</aside>
{/* Main Chat */}
<main className="flex flex-1 flex-col">
<Conversation className="flex-1 p-4">
<ConversationContent>
{messages.map((message) => (
<div key={message.id} className="flex flex-col gap-2">
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
</Message>
);
case 'reasoning':
return (
<Reasoning
key={i}
isStreaming={
status === 'streaming' &&
message.id === messages.at(-1)?.id
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
default:
// Handle tool parts
if (part.type.startsWith('tool-')) {
return (
<Tool key={i}>
<ToolHeader
type={part.type}
state={part.state}
/>
<ToolContent>
<ToolInput input={part.input} />
<ToolOutput
output={part.output}
errorText={part.errorText}
/>
</ToolContent>
</Tool>
);
}
return null;
}
})}
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="border-t p-4">
<PromptInput onSubmit={handleSubmit}>
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask the agent anything..."
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
</main>
</div>
);
}---
Multi-Agent Setup
Multiple Agents
// ai/research.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const researchAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a research assistant. Find and summarize information.',
tools: {
searchWeb: tool({
description: 'Search for information',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => ({ results: [`Found: ${query}`] }),
}),
},
stopWhen: stepCountIs(10),
});// ai/code.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const codeAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a coding assistant. Write and review code.',
tools: {
runCode: tool({
description: 'Execute code',
inputSchema: z.object({ code: z.string(), language: z.string() }),
execute: async ({ code, language }) => ({
output: `Executed ${language} code`,
}),
}),
},
stopWhen: stepCountIs(15),
});// ai/writing.ts
import { ToolLoopAgent, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const writingAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a writing assistant. Help with content creation.',
tools: {},
stopWhen: stepCountIs(10),
});Agent Router API
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from 'ai';
import { researchAgent } from '@/ai/research';
import { codeAgent } from '@/ai/code';
import { writingAgent } from '@/ai/writing';
export const maxDuration = 30;
const agents = {
research: researchAgent,
code: codeAgent,
writing: writingAgent,
};
export async function POST(request: Request) {
const { messages, agentType = 'research' } = await request.json();
const agent = agents[agentType as keyof typeof agents] || researchAgent;
return createAgentUIStreamResponse({
agent,
uiMessages: messages,
sendSources: true,
});
}Agent Selector UI
// Add to dashboard
const [agentType, setAgentType] = useState<'research' | 'code' | 'writing'>('research');
// In handleSubmit:
sendMessage(
{ text: message.text },
{ body: { agentType } }
);
// Agent selector component
<div className="flex gap-2 mb-4">
{['research', 'code', 'writing'].map((type) => (
<button
key={type}
onClick={() => setAgentType(type as any)}
className={cn(
'px-3 py-1 rounded-full text-sm',
agentType === type ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
</div>---
Tool Approval Flow (Human-in-the-Loop)
For sensitive tools that require user confirmation before execution.
In AI SDK 6, tools requiring approval set `needsApproval: true` and keep their execute function. The agent loop pauses before execution and emits an approval-requested state on the typed tool part, allowing the client to handle approval. After the user responds, the loop resumes and execute runs (if approved).
Agent with Approval Tools
// ai/admin-agent.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const adminAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are an admin assistant with access to sensitive operations.',
tools: {
// Tool requiring approval - agent loop pauses for approval before execute runs
deleteFile: tool({
description: 'Delete a file (requires approval)',
inputSchema: z.object({
path: z.string().describe('File path to delete'),
}),
outputSchema: z.object({ success: z.boolean(), message: z.string() }),
needsApproval: true,
execute: async ({ path }) => {
// Runs only AFTER the user approves
return { success: true, message: `Deleted ${path}` };
},
}),
// Tool with automatic execution
readFile: tool({
description: 'Read file contents',
inputSchema: z.object({
path: z.string().describe('File path to read'),
}),
execute: async ({ path }) => {
return { content: `Contents of ${path}` };
},
}),
},
stopWhen: stepCountIs(20),
});Approval UI
The pending approval surfaces as a typed tool part (tool-deleteFile) with state approval-requested. Send the decision with addToolApprovalResponse, and set sendAutomaticallyWhen so the conversation resumes once every approval has a response.
import { useChat } from '@ai-sdk/react';
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from 'ai';
import {
Confirmation,
ConfirmationRequest,
ConfirmationAccepted,
ConfirmationRejected,
ConfirmationActions,
ConfirmationAction,
} from '@/components/ai-elements/confirmation';
// In your component:
const { messages, sendMessage, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
// Re-sends automatically after every approval/denial has a response
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
// In message parts rendering - match the typed tool part awaiting approval
if (part.type === 'tool-deleteFile' && part.state === 'approval-requested' && part.approval) {
return (
<Confirmation key={i} approval={part.approval} state={part.state}>
<ConfirmationRequest>
<p className="text-sm text-muted-foreground">
Tool <code>deleteFile</code> requires approval
</p>
<p className="text-sm text-muted-foreground">
Input: {JSON.stringify(part.input)}
</p>
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-sm text-green-600">Approved</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-sm text-red-600">Denied</p>
</ConfirmationRejected>
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: false })
}
>
Deny
</ConfirmationAction>
<ConfirmationAction
onClick={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: true })
}
>
Allow
</ConfirmationAction>
</ConfirmationActions>
</Confirmation>
);
}See Human-in-the-Loop Cookbook for more details.
---
Reference
For more details, see:
/ai-sdk-6skill → agents.md - Full agent API/ai-sdk-6skill → tools.md - Tool definitions/ai-elementsskill → chatbot.md - Tool component
Chatbot Templates
Complete templates for building AI chatbots.
Minimal Chatbot
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages: await convertToModelMessages(messages),
system: 'You are a helpful assistant.',
});
return result.toUIMessageStreamResponse();
}Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import { Loader } from '@/components/ai-elements/loader';
import { useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput('');
};
return (
<div className="mx-auto flex h-screen max-w-3xl flex-col p-4">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) =>
part.type === 'text' ? (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
</Message>
) : null
)}
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}---
Full-Featured Chatbot
With reasoning, sources, file attachments, model selector, and message actions.
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const maxDuration = 30;
export async function POST(req: Request) {
const {
messages,
model = 'claude-sonnet-4-6',
}: {
messages: UIMessage[];
model?: string;
} = await req.json();
const result = streamText({
model: anthropic(model),
messages: await convertToModelMessages(messages),
system: 'You are a helpful assistant. Think step by step.',
});
return result.toUIMessageStreamResponse({
sendSources: true,
sendReasoning: true,
});
}Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
MessageActions,
MessageAction,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputHeader,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputSubmit,
PromptInputAttachments,
PromptInputAttachment,
PromptInputActionMenu,
PromptInputActionMenuTrigger,
PromptInputActionMenuContent,
PromptInputActionAddAttachments,
PromptInputSelect,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSelectContent,
PromptInputSelectItem,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from '@/components/ai-elements/reasoning';
import {
Sources,
SourcesTrigger,
SourcesContent,
Source,
} from '@/components/ai-elements/sources';
import { Loader } from '@/components/ai-elements/loader';
import { CopyIcon, RefreshCcwIcon } from 'lucide-react';
import { useState } from 'react';
const models = [
{ name: 'Claude Sonnet', value: 'claude-sonnet-4-6' },
{ name: 'Claude Haiku', value: 'claude-haiku-4-5' },
];
export default function ChatPage() {
const [input, setInput] = useState('');
const [model, setModel] = useState(models[0].value);
const { messages, sendMessage, status, regenerate } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim() && !message.files?.length) return;
sendMessage(
{ text: message.text || 'Sent with attachments', files: message.files },
{ body: { model } }
);
setInput('');
};
return (
<div className="mx-auto flex h-screen max-w-4xl flex-col p-6">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => {
const sourceUrls = message.parts.filter((p) => p.type === 'source-url');
return (
<div key={message.id}>
{/* Sources at top */}
{message.role === 'assistant' && sourceUrls.length > 0 && (
<Sources>
<SourcesTrigger count={sourceUrls.length} />
<SourcesContent>
{sourceUrls.map((part, i) => (
<Source key={i} href={part.url} title={part.title} />
))}
</SourcesContent>
</Sources>
)}
{/* Message parts */}
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
{message.role === 'assistant' && (
<MessageActions>
<MessageAction
label="Retry"
onClick={() => regenerate()}
>
<RefreshCcwIcon data-icon />
</MessageAction>
<MessageAction
label="Copy"
onClick={() => navigator.clipboard.writeText(part.text)}
>
<CopyIcon data-icon />
</MessageAction>
</MessageActions>
)}
</Message>
);
case 'reasoning':
return (
<Reasoning
key={i}
isStreaming={
status === 'streaming' &&
message.id === messages.at(-1)?.id
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
default:
return null;
}
})}
</div>
);
})}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4" globalDrop multiple>
<PromptInputHeader>
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputSelect value={model} onValueChange={setModel}>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((m) => (
<PromptInputSelectItem key={m.value} value={m.value}>
{m.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}---
Web Search Chatbot
With Perplexity integration for web search. Perplexity has built-in web search and returns sources automatically.
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { perplexity } from '@ai-sdk/perplexity';
export const maxDuration = 30;
export async function POST(req: Request) {
const {
messages,
webSearch = false,
}: {
messages: UIMessage[];
webSearch?: boolean;
} = await req.json();
const result = streamText({
// Use Perplexity for web search, Claude otherwise
model: webSearch ? perplexity('sonar-pro') : anthropic('claude-sonnet-4-6'),
messages: await convertToModelMessages(messages),
system: webSearch
? 'Search the web and provide accurate, up-to-date information with sources.'
: 'You are a helpful assistant.',
});
return result.toUIMessageStreamResponse({
sendSources: true,
sendReasoning: true,
});
}Perplexity ships multiple Sonar variants (base, pro, reasoning, deep-research) — check the Perplexity docs for the current set before hard-coding a name.
Alternative: OpenAI Web Search Tool
If already using OpenAI, use the built-in web search tool with an agent instead:
// ai/assistant.ts
import { ToolLoopAgent, stepCountIs } from 'ai';
import { openai } from '@ai-sdk/openai';
export const searchAgent = new ToolLoopAgent({
model: openai('gpt-5.4'),
instructions: 'Search the web and provide accurate information with sources.',
tools: {
web_search: openai.tools.webSearch({
searchContextSize: 'medium',
}),
},
stopWhen: stepCountIs(5),
});
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from 'ai';
import { searchAgent } from '@/ai/assistant';
export async function POST(req: Request) {
const { messages } = await req.json();
return createAgentUIStreamResponse({
agent: searchAgent,
uiMessages: messages,
sendSources: true,
});
}Web search results appear as source-url message parts — render with the Sources component.
Chat Page Addition
Add web search toggle to PromptInput:
import { GlobeIcon } from 'lucide-react';
import { PromptInputButton } from '@/components/ai-elements/prompt-input';
// In component:
const [webSearch, setWebSearch] = useState(false);
// In handleSubmit:
sendMessage(
{ text: message.text },
{ body: { webSearch } }
);
// In PromptInputTools:
<PromptInputButton
variant={webSearch ? 'default' : 'ghost'}
onClick={() => setWebSearch(!webSearch)}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>---
Chatbot with Follow-up Suggestions
Add AI-generated follow-up suggestions after each assistant response.
Suggestions API Route
// app/api/suggestions/route.ts
import { generateText, Output } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
export async function POST(req: Request) {
const { question, answer }: { question: string; answer: string } =
await req.json();
const { output } = await generateText({
model: openai('gpt-5.4-mini'),
output: Output.array({
schema: z.string().describe('A follow-up question'),
}),
prompt: `Based on this Q&A, suggest 2-3 natural follow-up questions.
Question: ${question}
Answer: ${answer}`,
});
return Response.json(output ?? []);
}Client Integration
// hooks/use-suggestions.ts
import { useCallback, useEffect, useRef, useState } from 'react';
export function useSuggestions(
messages: { role: string; parts: { type: string; text?: string }[] }[],
status: string,
) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const prevStatus = useRef(status);
// Fetch suggestions when generation completes
useEffect(() => {
const wasStreaming = prevStatus.current === 'streaming';
prevStatus.current = status;
if (!wasStreaming || status !== 'ready') return;
if (messages.length < 2) return;
const lastUser = messages.findLast((m) => m.role === 'user');
const lastAssistant = messages.findLast((m) => m.role === 'assistant');
if (!lastUser || !lastAssistant) return;
const question = lastUser.parts.find((p) => p.type === 'text')?.text ?? '';
const answer =
lastAssistant.parts.find((p) => p.type === 'text')?.text ?? '';
setIsLoading(true);
fetch('/api/suggestions', {
method: 'POST',
body: JSON.stringify({ question, answer }),
})
.then((r) => r.json())
.then(setSuggestions)
.finally(() => setIsLoading(false));
}, [status, messages]);
const clear = useCallback(() => setSuggestions([]), []);
return { suggestions, isLoadingSuggestions: isLoading, clearSuggestions: clear };
}Chat Page with Suggestions
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion';
import { Loader } from '@/components/ai-elements/loader';
import { useSuggestions } from '@/hooks/use-suggestions';
// Inside your chat component:
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const { suggestions, isLoadingSuggestions, clearSuggestions } =
useSuggestions(messages, status);
const handleSuggestionClick = (suggestion: string) => {
clearSuggestions();
sendMessage({ text: suggestion });
};
// After messages in ConversationContent:
{!isGenerating && (isLoadingSuggestions || suggestions.length > 0) && (
<div className="pt-2">
{isLoadingSuggestions ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader size={12} />
<span>Loading suggestions…</span>
</div>
) : (
<Suggestions>
{suggestions.map((s, i) => (
<Suggestion key={i} suggestion={s} onClick={handleSuggestionClick}>
{s}
</Suggestion>
))}
</Suggestions>
)}
</div>
)}---
Component Reference
For detailed component documentation, see /ai-elements skill:
- Conversation
- Message
- PromptInput
- Reasoning
- Sources
Copy-Paste Examples
Ready-to-use examples for common AI application patterns.
---
Minimal Chatbot
The simplest possible chatbot implementation.
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import { useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput('');
};
return (
<div className="mx-auto flex h-screen max-w-2xl flex-col p-4">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts
.filter((p) => p.type === 'text')
.map((part, i) => (
<MessageResponse key={i}>{part.text}</MessageResponse>
))}
</MessageContent>
</Message>
))}
</ConversationContent>
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}---
Full-Featured Chatbot
Chatbot with reasoning, sources, and file attachments.
API Route
// app/api/chat/route.ts
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages: await convertToModelMessages(messages),
system: `You are a helpful AI assistant. When appropriate:
- Use extended thinking to reason through complex problems
- Cite sources when providing factual information
- Be concise but thorough`,
providerOptions: {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: 10000,
},
},
},
});
return result.toUIMessageStreamResponse({
sendReasoning: true,
sendSources: true,
});
}Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
MessageAttachment,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
PromptInputFileDropdown,
PromptInputAttachments,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from '@/components/ai-elements/reasoning';
import {
Sources,
SourcesTrigger,
SourcesContent,
Source,
} from '@/components/ai-elements/sources';
import { Loader } from '@/components/ai-elements/loader';
import { useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const [files, setFiles] = useState<File[]>([]);
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim() && message.files.length === 0) return;
sendMessage({ text: message.text, files: message.files });
setInput('');
setFiles([]);
};
const isLastMessage = (id: string) => id === messages.at(-1)?.id;
return (
<div className="mx-auto flex h-screen max-w-3xl flex-col p-4">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => (
<div key={message.id} className="flex flex-col gap-2">
{/* User attachments */}
{message.role === 'user' && message.attachments?.length > 0 && (
<div className="flex flex-wrap gap-2">
{message.attachments.map((att, i) => (
<MessageAttachment key={i} attachment={att} />
))}
</div>
)}
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
</Message>
);
case 'reasoning':
return (
<Reasoning
key={i}
isStreaming={
status === 'streaming' && isLastMessage(message.id)
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
case 'source-url':
return null; // Collected below
default:
return null;
}
})}
{/* Collect and display sources */}
{(() => {
const sourceUrls = message.parts.filter((p) => p.type === 'source-url');
if (sourceUrls.length === 0) return null;
return (
<Sources>
<SourcesTrigger count={sourceUrls.length} />
<SourcesContent>
{sourceUrls.map((part, i) => (
<Source
key={i}
href={part.url}
title={part.title}
/>
))}
</SourcesContent>
</Sources>
);
})()}
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputAttachments files={files} setFiles={setFiles} />
<PromptInputBody>
<PromptInputFileDropdown files={files} setFiles={setFiles} />
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me anything..."
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}---
Agent with Tools
An agent that can search the web and perform calculations.
Agent Definition
// ai/assistant.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const assistantAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: `You are a helpful assistant with access to tools.
When you need current information, use the searchWeb tool.
When calculating, use the calculator tool.
Always explain what you're doing before using a tool.`,
tools: {
searchWeb: tool({
description: 'Search the web for current information',
inputSchema: z.object({
query: z.string().describe('The search query'),
}),
execute: async ({ query }) => {
// Replace with actual search implementation
// e.g., Tavily, Serper, or Perplexity API
return {
results: [
{ title: 'Result 1', snippet: `Information about ${query}` },
{ title: 'Result 2', snippet: `More about ${query}` },
],
};
},
}),
calculator: tool({
description: 'Perform mathematical calculations',
inputSchema: z.object({
expression: z.string().describe('Math expression (e.g., "2 + 2")'),
}),
execute: async ({ expression }) => {
// Use mathjs for safe expression evaluation
const { evaluate } = await import('mathjs');
try {
const result = evaluate(expression);
return { result: String(result) };
} catch {
return { error: 'Invalid expression' };
}
},
}),
getCurrentDate: tool({
description: 'Get the current date and time',
inputSchema: z.object({}),
execute: async () => ({
date: new Date().toLocaleDateString(),
time: new Date().toLocaleTimeString(),
}),
}),
},
stopWhen: stepCountIs(15),
});API Route
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from 'ai';
import { assistantAgent } from '@/ai/assistant';
export const maxDuration = 60;
export async function POST(request: Request) {
const { messages } = await request.json();
return createAgentUIStreamResponse({
agent: assistantAgent,
uiMessages: messages,
sendSources: true,
includeUsage: true,
});
}Agent Chat Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { Loader } from '@/components/ai-elements/loader';
import { useState } from 'react';
export default function AgentPage() {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput('');
};
return (
<div className="mx-auto flex h-screen max-w-3xl flex-col p-4">
<Conversation className="flex-1">
<ConversationContent>
{messages.map((message) => (
<div key={message.id} className="flex flex-col gap-2">
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<Message key={i} from={message.role}>
<MessageContent>
<MessageResponse>{part.text}</MessageResponse>
</MessageContent>
</Message>
);
default:
// Handle typed tool parts (tool-<toolName>)
if (part.type.startsWith('tool-')) {
return (
<Tool key={i}>
<ToolHeader
type={part.type}
state={part.state}
/>
<ToolContent>
<ToolInput input={part.input} />
<ToolOutput
output={part.output}
errorText={part.errorText}
/>
</ToolContent>
</Tool>
);
}
return null;
}
})}
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me to search or calculate..."
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
);
}---
Multi-Agent System
Switch between specialized agents.
Agent Definitions
// ai/research.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const researchAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a research assistant. Find and summarize information.',
tools: {
search: tool({
description: 'Search for information',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => ({
results: [`Research results for: ${query}`],
}),
}),
},
stopWhen: stepCountIs(10),
});// ai/code.ts
import { ToolLoopAgent, tool, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
export const codeAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a coding assistant. Write and explain code.',
tools: {
runCode: tool({
description: 'Execute code snippet',
inputSchema: z.object({
code: z.string(),
language: z.enum(['javascript', 'python']),
}),
execute: async ({ code, language }) => ({
output: `Executed ${language}: ${code.slice(0, 50)}...`,
}),
}),
},
stopWhen: stepCountIs(10),
});// ai/writing.ts
import { ToolLoopAgent, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const writingAgent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: 'You are a writing assistant. Help with content creation.',
tools: {},
stopWhen: stepCountIs(10),
});
export type AgentType = 'research' | 'code' | 'writing';API Route with Agent Selection
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from 'ai';
import { researchAgent } from '@/ai/research';
import { codeAgent } from '@/ai/code';
import { writingAgent } from '@/ai/writing';
import type { AgentType } from '@/ai/writing';
export const maxDuration = 60;
const agents = {
research: researchAgent,
code: codeAgent,
writing: writingAgent,
};
export async function POST(request: Request) {
const { messages, agentType = 'research' } = await request.json();
const agent = agents[agentType as AgentType] || agents.research;
return createAgentUIStreamResponse({
agent,
uiMessages: messages,
sendSources: true,
});
}Agent Selector UI
// components/agent-selector.tsx
'use client';
import { cn } from '@/lib/utils';
import type { AgentType } from '@/ai/writing';
const agentInfo = {
research: { label: 'Research', icon: '🔍' },
code: { label: 'Code', icon: '💻' },
writing: { label: 'Writing', icon: '✍️' },
};
type AgentSelectorProps = {
value: AgentType;
onChange: (agent: AgentType) => void;
};
export function AgentSelector({ value, onChange }: AgentSelectorProps) {
return (
<div className="flex gap-2">
{(Object.keys(agentInfo) as AgentType[]).map((type) => (
<button
key={type}
onClick={() => onChange(type)}
className={cn(
'rounded-full px-3 py-1 text-sm transition-colors',
value === type
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
)}
>
{agentInfo[type].icon} {agentInfo[type].label}
</button>
))}
</div>
);
}Multi-Agent Page
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
import type { AgentType } from '@/ai/writing';
import { AgentSelector } from '@/components/agent-selector';
// ... other imports
export default function MultiAgentPage() {
const [input, setInput] = useState('');
const [agentType, setAgentType] = useState<AgentType>('research');
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage(
{ text: message.text },
{ body: { agentType } }
);
setInput('');
};
return (
<div className="mx-auto flex h-screen max-w-3xl flex-col p-4">
<div className="mb-4">
<AgentSelector value={agentType} onChange={setAgentType} />
</div>
{/* Rest of chat UI same as Agent with Tools example */}
</div>
);
}---
Environment Variables
# .env.local - Choose your provider(s)
# Anthropic (Claude)
ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (GPT-4, etc.)
OPENAI_API_KEY=sk-...
# Google Generative AI (Gemini)
GOOGLE_GENERATIVE_AI_API_KEY=...
# Perplexity (web search)
PERPLEXITY_API_KEY=pplx-...---
Quick Reference
| Example | Files | Key Features |
|---|---|---|
| Minimal Chatbot | route.ts, page.tsx | Basic chat, streamText |
| Full-Featured | route.ts, page.tsx | Reasoning, sources, attachments |
| Agent with Tools | ai/assistant.ts, route.ts, page.tsx | ToolLoopAgent, tool visualization |
| Multi-Agent | ai/*.ts, route.ts, page.tsx | Agent switching, specialized agents |
See Also
- Chatbot Templates - More chatbot patterns
- Agent Dashboard - Dashboard layouts
- Project Structure - Full project setup
Project Structure
Standard directory layout for AI applications.
Basic Structure
my-ai-app/
├── app/ # Next.js App Router
│ ├── page.tsx # Main UI (chat/dashboard)
│ ├── layout.tsx # Root layout
│ ├── globals.css # Theme & global styles
│ └── api/
│ └── chat/
│ └── route.ts # AI API endpoint
├── components/
│ ├── ai-elements/ # AI Elements (installed)
│ │ ├── conversation.tsx
│ │ ├── message.tsx
│ │ ├── prompt-input.tsx
│ │ ├── reasoning.tsx
│ │ ├── sources.tsx
│ │ ├── tool.tsx
│ │ └── loader.tsx
│ └── ui/ # shadcn/ui components
│ ├── button.tsx
│ ├── input.tsx
│ └── ...
├── lib/
│ └── utils.ts # cn() utility
├── .env.local # API keys
├── next.config.ts # Next.js config
├── components.json # shadcn/ui config
└── package.jsonWith Agents
my-ai-app/
├── app/
│ ├── page.tsx
│ ├── layout.tsx
│ ├── globals.css
│ └── api/
│ └── chat/
│ └── route.ts
├── ai/ # Agent definitions
│ ├── assistant.ts # Main assistant
│ ├── research.ts # Research agent
│ └── code.ts # Code agent
├── components/
│ ├── ai-elements/
│ └── ui/
├── lib/
│ ├── utils.ts
│ └── ai.ts # AI configuration (optional)
└── ...Multi-Page App
my-ai-app/
├── app/
│ ├── page.tsx # Landing/home
│ ├── layout.tsx
│ ├── globals.css
│ ├── (chat)/ # Chat route group
│ │ ├── chat/
│ │ │ └── page.tsx # Chat UI
│ │ └── layout.tsx # Chat layout
│ ├── (dashboard)/ # Dashboard route group
│ │ ├── dashboard/
│ │ │ └── page.tsx # Dashboard UI
│ │ └── layout.tsx # Dashboard layout
│ └── api/
│ └── chat/
│ └── route.ts
├── components/
│ ├── ai-elements/
│ ├── ui/
│ ├── chat/ # Chat-specific components
│ │ └── chat-messages.tsx
│ └── dashboard/ # Dashboard-specific components
│ └── agent-selector.tsx
├── ai/ # Agent definitions
├── lib/
└── ...File Purposes
Core Files
| File | Purpose |
|---|---|
app/page.tsx | Main application UI |
app/layout.tsx | Root layout with providers |
app/globals.css | CSS variables, Tailwind base |
app/api/chat/route.ts | AI streaming endpoint |
Configuration
| File | Purpose |
|---|---|
.env.local | API keys (not committed) |
next.config.ts | Next.js configuration |
tailwind.config.ts | Tailwind theme |
components.json | shadcn/ui settings |
Components
| Directory | Purpose |
|---|---|
components/ai-elements/ | AI Elements (from CLI) |
components/ui/ | shadcn/ui primitives |
components/chat/ | Chat-specific components |
components/dashboard/ | Dashboard components |
Agents
| File | Purpose |
|---|---|
ai/[name].ts | Individual agent definitions (use @/ai/assistant imports) |
Utilities
| File | Purpose |
|---|---|
lib/utils.ts | cn() and common utilities |
lib/ai.ts | AI configuration (optional) |
Environment Variables
# .env.local - Choose your provider(s)
# Anthropic (Claude)
ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (GPT-4, etc.)
OPENAI_API_KEY=sk-...
# Google Generative AI (Gemini)
GOOGLE_GENERATIVE_AI_API_KEY=...
# Perplexity (web search)
PERPLEXITY_API_KEY=pplx-...Package.json Scripts
{
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start",
"lint": "next lint"
}
}Dependencies
Required
{
"dependencies": {
"ai": "latest",
"@ai-sdk/react": "latest",
"@ai-sdk/anthropic": "latest",
"zod": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest"
}
}Optional Dependencies
{
"dependencies": {
"@ai-sdk/perplexity": "latest",
"mathjs": "latest"
}
}@ai-sdk/perplexity- Web search chatbot (Perplexity provider)mathjs- Safe math expression evaluation for calculator tools
AI Elements Dependencies
Installed automatically with bunx --bun ai-elements@latest:
use-stick-to-bottom- Auto-scrollstreamdown- Markdown streamingshiki- Syntax highlighting (for CodeBlock)motion- Animations (for Shimmer)@xyflow/react- Canvas/workflow (optional)
Layout Patterns
Choose based on your app's needs:
| Pattern | When to use |
|---|---|
| Root layout only | Minimal apps, providers only |
| Nested layouts | Shared UI per section (sidebar, navigation) |
template.tsx | Reset state/effects on navigation |
Root Layout Only
For simple single-page apps:
// app/layout.tsx - minimal, just providers
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Nested Layouts
For apps with sections that share UI:
app/
├── layout.tsx # Root (html, body, providers)
├── (chat)/
│ ├── layout.tsx # Chat chrome (sidebar, header)
│ └── page.tsx # Chat content only
└── (dashboard)/
├── layout.tsx # Dashboard chrome
└── page.tsx # Dashboard content onlytemplate.tsx
Use when state/effects should reset on navigation:
// app/template.tsx - resets on every navigation
export default function Template({ children }: { children: React.ReactNode }) {
return <AnimatedContainer>{children}</AnimatedContainer>;
}Decision guide:
- State should persist →
layout.tsx - State should reset →
template.tsx - Per-page analytics/animations →
template.tsx
Reference: https://nextjs.org/docs/app/api-reference/file-conventions/template
Best Practices
1. Keep agents separate - One file per agent in ai/ 2. Use route groups - (chat)/, (dashboard)/ for organization 3. Use nested layouts - Avoid repeating header/footer in pages 4. Extract components - Move complex UI to components/chat/ 5. Environment safety - Never commit .env.local 6. Type safety - Use InferAgentUIMessage for typed messages