Back to Articles
Next.jsVercel AI SDKAI ChatTypeScriptTutorial

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

Frank Atukunda
Frank Atukunda
Software Engineer
December 17, 2025
6 min read
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-chat

Accept the default options (or customize as you prefer).

Install the AI SDK:

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

Step 2: Configure Environment

Create a .env.local file in your project root:

# .env.local
OPENAI_API_KEY=sk-proj-your-key-here

Make 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:

  • convertToModelMessages converts UI messages to the format expected by the model
  • streamText sends the messages to OpenAI and returns a stream
  • toUIMessageStreamResponse() streams the response in the format useChat expects

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:

  • DefaultChatTransport handles the streaming protocol with the API
  • sendMessage({ text: input }) sends the user message
  • message.parts contains the message content as an array of typed parts
  • status tells 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 dev

Open 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/anthropic
import { 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:

  1. Persist messages to a database (Supabase, PlanetScale)
  2. Add authentication so users have their own chat history
  3. Stream structured data with useObject for richer responses
  4. Add tools so the AI can call your APIs

Related Posts:


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 dev

Happy building!

0claps
Frank Atukunda

Frank Atukunda

Software Engineer documenting my transition to AI Engineering. Building 10x .dev to share what I learn along the way.

Share this article

Get more like this

Weekly insights on AI engineering for developers.

Comments (0)

Join the discussion

Sign in with GitHub to leave a comment and connect with other engineers.

No comments yet. Be the first to share your thoughts!