Build an AI Chatbot with Next.js 15 and OpenAI — Full Stack 2026
Advertisement
Introduction
Why This Matters
Shipping an AI chatbot is now a standard feature requirement for SaaS products, internal tools, and developer platforms. The technical challenge has shifted from "how do I call an LLM" to "how do I build a production-quality streaming chat UI with proper rate limiting, error handling, and persistent history."
The Vercel AI SDK (ai package) standardizes the streaming protocol between Next.js edge functions and React clients, eliminating the need to build custom SSE infrastructure. Combined with Next.js 15 App Router and OpenAI GPT-4o, you can ship a production chatbot in hours rather than days.
This guide builds the complete stack: streaming API route, chat UI with markdown rendering, chat history persistence, rate limiting middleware, and Vercel deployment.
Project Setup
npx create-next-app@latest ai-chatbot --typescript --tailwind --app
cd ai-chatbot
npm install openai ai @ai-sdk/openai react-markdown react-syntax-highlighter
npm install @upstash/ratelimit @upstash/redisStreaming API Route
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText, Message } from 'ai'
export const runtime = 'edge'
export const maxDuration = 30
export async function POST(req: Request) {
const { messages }: { messages: Message[] } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
system: `You are a helpful assistant. Be concise, friendly, and accurate.
Format code blocks with proper syntax highlighting markers.
Today's date is ${new Date().toDateString()}.`,
messages,
maxTokens: 1000,
})
return result.toDataStreamResponse()
}runtime = 'edge' deploys to Vercel's edge network, giving sub-100ms first-token latency globally.
Chat Message Component
// components/ChatMessage.tsx
import { Message } from 'ai'
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
export function ChatMessage({ message }: { message: Message }) {
const isUser = message.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
<ReactMarkdown
components={{
code({ className, children }) {
const language = /language-(\w+)/.exec(className || '')?.[1]
return language ? (
<SyntaxHighlighter style={oneDark} language={language} PreTag="div">
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded text-sm">
{children}
</code>
)
},
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
)
}Main Chat Page
// app/page.tsx
'use client'
import { useChat } from 'ai/react'
import { ChatMessage } from '@/components/ChatMessage'
import { useEffect, useRef } from 'react'
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: '/api/chat',
onError: (err) => console.error('Chat error:', err),
})
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
return (
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
<header className="border-b border-gray-200 dark:border-gray-700 p-4">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">AI Assistant</h1>
<p className="text-sm text-gray-500">Powered by GPT-4o</p>
</header>
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<p className="text-2xl mb-2">How can I help you today?</p>
</div>
)}
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{isLoading && (
<div className="flex justify-start mb-4">
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl px-4 py-3">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
{error && <div className="text-red-500 text-center p-2">Error: {error.message}</div>}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="flex gap-2 max-w-4xl mx-auto">
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-3
bg-white dark:bg-gray-800 text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white
rounded-xl px-6 py-3 font-medium transition-colors"
>
Send
</button>
</div>
</form>
</div>
)
}Rate Limiting Middleware
// middleware.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1m'), // 10 requests per minute per IP
})
export async function middleware(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
}
export const config = { matcher: '/api/chat' }Chat History with localStorage
// hooks/useChatHistory.ts
import { useState, useEffect } from 'react'
import { Message } from 'ai'
export function useChatHistory(chatId: string) {
const [history, setHistory] = useState<Message[]>([])
useEffect(() => {
const saved = localStorage.getItem(`chat-${chatId}`)
if (saved) setHistory(JSON.parse(saved))
}, [chatId])
const saveHistory = (messages: Message[]) => {
localStorage.setItem(`chat-${chatId}`, JSON.stringify(messages))
setHistory(messages)
}
return { history, saveHistory }
}Deploy to Vercel
npm run build
vercel deploy
# Set OPENAI_API_KEY in Vercel project settings
# Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN for rate limitingCommon Mistakes / Pitfalls
- No rate limiting on the API route — without it, a single user can exhaust your OpenAI budget in minutes
- Not using
runtime = 'edge'— default Node.js runtime adds 200-500ms cold start latency - Sending full message history on every request — trim history to the last N turns to control token costs
- Not handling streaming errors gracefully — network interruptions mid-stream need visible error states
- No
maxTokenslimit — unbounded responses inflate costs and slow down the streaming experience
Best Practices
- Implement
maxTokenson every API call to cap response cost and length - Compress old messages to summaries when conversation history exceeds 20 turns
- Use Upstash Redis for rate limiting — it is the edge-compatible Redis that works with Vercel
- Display a clear loading state with animated dots while streaming is in progress
- Store chat history in a database (Postgres/Supabase) for multi-device sync in production
Key Takeaways
- The Vercel AI SDK
useChathook handles streaming, message state, and error recovery in one import runtime = 'edge'on the API route enables global sub-100ms first-token latency via Vercel Edge Network- Upstash Redis with sliding window rate limiting prevents any single user from draining your OpenAI quota
- ReactMarkdown with syntax highlighting renders code blocks correctly from LLM responses
- localStorage chat history works for single-device use; use Supabase or Postgres for production persistence
- Trimming conversation history to the last 10-20 turns keeps token costs predictable at scale
- The Vercel AI SDK is provider-agnostic — swapping
openaiforanthropicorgooglerequires one import change - A production chatbot needs rate limiting, error boundaries, streaming fallbacks, and cost monitoring from day one
Advertisement