Building Your First AI Chat App with Vercel AI SDK and Next.js

You're about to build your first AI chat app. Not a demo. Not a sandbox. A real, streaming chatbot that you can customize and deploy.
Time required: 15 minutes Prerequisites: Node.js 18+, an OpenAI API key
Let's build.
What We're Building
A ChatGPT-style interface with:
- Real-time streaming responses
- Clean, responsive UI
- Loading states and error handling
- Enter key to send, auto-scroll to latest message
Step 1: Create Next.js Project
Open your terminal and run:
npx create-next-app@latest my-ai-chat --typescript --app --eslint
cd my-ai-chatAccept the default options (or customize as you prefer).
Install the AI SDK:
npm install ai @ai-sdk/openai @ai-sdk/reactStep 2: Configure Environment
Create a .env.local file in your project root:
# .env.local
OPENAI_API_KEY=sk-proj-your-key-hereMake sure .env.local is in your .gitignore (Next.js does this by default).
Step 3: Create the API Route
Create a new file at app/api/chat/route.ts:
// app/api/chat/route.ts
import { streamText, convertToModelMessages, UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant. Be concise and friendly.',
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}What's happening here:
convertToModelMessagesconverts UI messages to the format expected by the modelstreamTextsends the messages to OpenAI and returns a streamtoUIMessageStreamResponse()streams the response in the formatuseChatexpects
Step 4: Build the Chat UI
Replace the contents of app/page.tsx:
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useEffect, useRef, useState } from 'react';
export default function Chat() {
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const isLoading = status === 'streaming' || status === 'submitted';
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput('');
};
return (
<main className="chat-container">
<header className="chat-header">
<h1>AI Chat</h1>
</header>
<div className="messages">
{messages.length === 0 && (
<div className="empty-state">
<p>Send a message to start the conversation.</p>
</div>
)}
{messages.map((m) => (
<div key={m.id} className={`message ${m.role}`}>
<div className="message-role">
{m.role === 'user' ? 'You' : 'AI'}
</div>
<div className="message-content">
{m.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.text}</span> : null
)}
</div>
</div>
))}
{isLoading && (
<div className="message assistant">
<div className="message-role">AI</div>
<div className="message-content typing">Thinking...</div>
</div>
)}
{error && (
<div className="error-message">
Error: {error.message}
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={onSubmit} className="input-form">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
autoFocus
/>
<button type="submit" disabled={isLoading || !input.trim()}>
{isLoading ? '...' : 'Send'}
</button>
</form>
</main>
);
}What's happening here:
DefaultChatTransporthandles the streaming protocol with the APIsendMessage({ text: input })sends the user messagemessage.partscontains the message content as an array of typed partsstatustells us if we're streaming ('streaming' or 'submitted')
Step 5: Style the Interface
Replace the contents of app/globals.css:
/* app/globals.css */
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #1a1a1a;
--bg-user: #2563eb;
--bg-assistant: #27272a;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--border: #27272a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
.chat-container {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
text-align: center;
}
.chat-header h1 {
font-size: 1.25rem;
font-weight: 600;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.message {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 1rem;
}
.message.user {
align-self: flex-end;
background: var(--bg-user);
}
.message.assistant {
align-self: flex-start;
background: var(--bg-assistant);
}
.message-role {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.message-content {
line-height: 1.5;
white-space: pre-wrap;
}
.typing {
opacity: 0.7;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 0.4; }
}
.error-message {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
text-align: center;
}
.input-form {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.input-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 1rem;
}
.input-form input:focus {
outline: none;
border-color: var(--bg-user);
}
.input-form input:disabled {
opacity: 0.5;
}
.input-form button {
padding: 0.75rem 1.5rem;
background: var(--bg-user);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.input-form button:hover:not(:disabled) {
opacity: 0.9;
}
.input-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}Step 6: Run Your App
Start the development server:
npm run devOpen http://localhost:3000 and start chatting!
Adding More Features
Change the AI Personality
Edit the system prompt in app/api/chat/route.ts:
system: 'You are a pirate. Respond to everything like a pirate would.',Use a Different Model
Switch to GPT-4o-mini for faster, cheaper responses:
model: openai('gpt-4o-mini'),Or switch to Claude:
npm install @ai-sdk/anthropicimport { anthropic } from '@ai-sdk/anthropic';
model: anthropic('claude-3-5-sonnet-20241022'),Add a Clear Chat Button
Add setMessages to your useChat hook and a clear button:
const { messages, sendMessage, setMessages } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
// Add a clear button in the header
<button onClick={() => setMessages([])}>Clear Chat</button>What's Next
You've built a working AI chat app. Here are some ideas to take it further:
- Persist messages to a database (Supabase, PlanetScale)
- Add authentication so users have their own chat history
- Stream structured data with
useObjectfor richer responses - Add tools so the AI can call your APIs
Related Posts:
- Vercel AI SDK: The Complete Guide - Deep dive into all SDK features
- Understanding AI Model Parameters - Tune temperature and top_p
Full Code
All code from this tutorial is available on GitHub: atukunda256/my-ai-chat
Clone and run:
git clone https://github.com/atukunda256/my-ai-chat
cd my-ai-chat
npm install
# Add your OPENAI_API_KEY to .env.local
npm run devHappy building!

Frank Atukunda
Software Engineer documenting my transition to AI Engineering. Building 10x .dev to share what I learn along the way.
Comments (0)
Join the discussion
Sign in with GitHub to leave a comment and connect with other engineers.