Build an AI Chatbot with Next.js 15 and OpenAI — Full Stack 2026

Sanjeev SharmaSanjeev Sharma
6 min read

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/redis

Streaming 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 limiting

Common 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 maxTokens limit — unbounded responses inflate costs and slow down the streaming experience

Best Practices

  • Implement maxTokens on 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 useChat hook 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 openai for anthropic or google requires one import change
  • A production chatbot needs rate limiting, error boundaries, streaming fallbacks, and cost monitoring from day one

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading