Deploy a Vercel AI SDK chatbot to production requires more than copying the quickstart into a repository. You need streaming responses that don’t time out, an edge runtime that cold-starts fast, proper environment hygiene, and observability so you can debug when the model hallucinates at 2 AM. This guide walks through the complete path from a fresh Next.js app to a production deployment on Vercel, with code you can run at each step.
Step 1: Initialize the project and install dependencies
Start with a clean Next.js 14+ project using the app router and TypeScript. The AI SDK v4 separates core utilities from provider adapters, so install only what you need.
npx create-next-app@latest ai-chatbot --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
cd ai-chatbot
npm install ai @ai-sdk/openai @ai-sdk/anthropic zod
The ai package contains the streaming primitives and React hooks. Provider packages (@ai-sdk/openai, @ai-sdk/anthropic) are tree-shakable — import only the models you use. zod validates tool schemas at runtime.
Verify the install:
npm run dev
You should see the Next.js landing page at http://localhost:3000.
Step 2: Create the chat API route with streaming
The AI SDK’s streamText function handles the heavy lifting: request formatting, provider normalization, and chunked responses. Create src/app/api/chat/route.ts:
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { anthropic } from '@ai-sdk/anthropic'
export const runtime = 'edge'
export async function POST(req: Request) {
const { messages, model } = await req.json()
const selectedModel = model === 'anthropic'
? anthropic('claude-3-5-sonnet-20241022')
: openai('gpt-4o-mini')
const result = await streamText({
model: selectedModel,
messages,
temperature: 0.7,
maxTokens: 2048,
onError: (error) => {
console.error('[chat] streaming error:', error)
},
})
return result.toDataStreamResponse()
}
Key decisions here:
runtime = 'edge'opts into Vercel’s Edge Runtime. Cold starts are ~50ms vs ~1s for Node.js, and streaming starts immediately without buffering.- The
modelfield in the request body lets the client switch providers without deploying new code. toDataStreamResponse()returns aReadableStreamcompatible with the AI SDK’suseChathook.
Test the endpoint locally:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Say hello in one sentence"}],"model":"openai"}'
You should see a streaming response with data: chunks.
Step 3: Build the chat interface
The useChat hook manages message state, streaming, and retry logic. Replace src/app/page.tsx:
'use client'
import { useChat } from 'ai/react'
import { useState } from 'react'
export default function Chat() {
const [model, setModel] = useState<'openai' | 'anthropic'>('openai')
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: '/api/chat',
body: { model },
onError: (err) => console.error('[chat] client error:', err),
})
return (
<main className="flex min-h-screen flex-col items-center p-4">
<header className="mb-4 w-full max-w-2xl">
<h1 className="text-2xl font-semibold">AI Chatbot</h1>
<p className="text-sm text-gray-500">Model: {model}</p>
</header>
<div className="w-full max-w-2xl space-y-4">
{messages.map((m) => (
<div
key={m.id}
className={`flex gap-3 ${m.role === 'assistant' ? 'justify-start' : 'justify-end'}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
m.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-gray-100 text-gray-900 rounded-bl-none'
}`}
>
{m.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start gap-3">
<div className="bg-gray-100 text-gray-900 rounded-2xl px-4 py-2 rounded-bl-none animate-pulse">
…
</div>
</div>
)}
{error && (
<div className="text-sm text-red-600 text-center">
Error: {error.message}.{' '}
<button
onClick={() => handleSubmit(new FormData())}
className="underline hover:text-red-700"
>
Retry
</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<select
value={model}
onChange={(e) => setModel(e.target.value as 'openai' | 'anthropic')}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="openai">GPT-4o Mini</option>
<option value="anthropic">Claude 3.5 Sonnet</option>
</select>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message…"
className="flex-1 border rounded-lg px-4 py-2 text-sm"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm disabled:opacity-50"
>
Send
</button>
</form>
</div>
</main>
)
}
The hook handles:
- Optimistic UI updates (user message appears instantly)
- Streaming token accumulation
- Automatic retry on network errors
- Abort controller cleanup on unmount
Run npm run dev and open http://localhost:3000. Send a message — you should see tokens stream in real time.
Step 4: Configure environment variables
Never commit API keys. Create .env.local for local development:
# .env.local
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
Add both keys in Vercel’s project settings under Environment Variables for Production, Preview, and Development environments. The Edge Runtime reads process.env at request time, so no rebuild is needed when keys rotate.
If you route through a gateway like n4n.ai that consolidates 240+ models behind one OpenAI-compatible endpoint, you would set a single OPENAI_BASE_URL and OPENAI_API_KEY instead of managing provider keys individually. The API route stays unchanged — just swap the base URL.
Step 5: Harden the API route for production
The minimal route works locally but needs guardrails before traffic hits it. Update src/app/api/chat/route.ts:
import { streamText, CoreMessage } from 'ai'
import { openai } from '@ai-sdk/openai'
import { anthropic } from '@ai-sdk/anthropic'
import { z } from 'zod'
export const runtime = 'edge'
const RequestSchema = z.object({
messages: z.array(
z.object({
role: z.enum(['user', 'assistant', 'system', 'tool']),
content: z.string(),
})
),
model: z.enum(['openai', 'anthropic']).default('openai'),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().min(1).max(8192).optional(),
})
const SYSTEM_PROMPT = `You are a helpful assistant. Be concise.`
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
const parsed = RequestSchema.safeParse(body)
if (!parsed.success) {
return new Response(JSON.stringify(parsed.error.flatten()), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const { messages, model, temperature = 0.7, maxTokens = 2048 } = parsed.data
const selectedModel = model === 'anthropic'
? anthropic('claude-3-5-sonnet-20241022')
: openai('gpt-4o-mini')
const coreMessages: CoreMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...messages,
]
try {
const result = await streamText({
model: selectedModel,
messages: coreMessages,
temperature,
maxTokens,
onError: (error) => {
console.error('[chat] streaming error:', {
model,
messageCount: messages.length,
error: error instanceof Error ? error.message : String(error),
})
},
})
return result.toDataStreamResponse({
sendReasoning: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
Changes:
- Zod validation rejects malformed payloads before they reach the model.
- Explicit
CoreMessagetyping prevents role/content mismatches. - Structured error logging includes model and message count for debugging.
sendReasoning: trueforwards reasoning tokens from models that support them (e.g., o1-series).
Step 6: Add rate limiting and abuse protection
Edge functions scale to zero but cost money per invocation. Add a lightweight rate limiter using Vercel KV (Redis) or an in-memory map for low-traffic apps. Install @vercel/kv:
npm install @vercel/kv
Create src/lib/rate-limit.ts:
import { kv } from '@vercel/kv'
export async function rateLimit(
identifier: string,
limit: number,
windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetMs: number }> {
const key = `ratelimit:${identifier}`
const now = Date.now()
const windowStart = now - windowMs
const pipeline = kv.pipeline()
pipeline.zremrangebyscore(key, 0, windowStart)
pipeline.zcard(key)
pipeline.zadd(key, { score: now, member: `${now}-${Math.random()}` })
pipeline.expire(key, Math.ceil(windowMs / 1000))
const results = await pipeline.exec()
const currentCount = (results[1] as number) ?? 0
const allowed = currentCount < limit
const remaining = Math.max(0, limit - currentCount - 1)
const resetMs = now + windowMs
return { allowed, remaining, resetMs }
}
Wire it into the chat route:
// At top of route.ts
import { rateLimit } from '@/lib/rate-limit'
import { headers } from 'next/headers'
// Inside POST, before validation:
const headersList = headers()
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const { allowed, remaining, resetMs } = await rateLimit(ip, 30, 60_000) // 30 req/min
if (!allowed) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((resetMs - Date.now()) / 1000)),
'X-RateLimit-Remaining': String(remaining),
},
})
}
Deploy to Vercel and enable Vercel KV in the Storage tab. The limiter survives cold starts because KV is external.
Step 7: Configure Vercel deployment settings
Create vercel.json at the repo root to pin the Edge Runtime and set function timeouts:
{
"functions": {
"src/app/api/chat/route.ts": {
"runtime": "edge",
"maxDuration": 30
}
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type" }
]
}
]
}
maxDuration: 30gives the stream 30 seconds before Vercel terminates it. Increase for long-running tool calls.- CORS headers allow the endpoint to be called from preview deployments or local development against a deployed API.
Push to GitHub and import the repository in Vercel. The build should complete in under a minute.
Step 8: Verify production deployment
After deployment, run these checks:
1. Health check the API endpoint:
curl -X POST https://your-app.vercel.app/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"ping"}],"model":"openai"}'
Expect a streaming data: response. No data: chunks means the Edge Function isn’t streaming — check runtime = 'edge' and toDataStreamResponse().
2. Test the full UI flow:
Open https://your-app.vercel.app. Send a message. Verify:
- User message appears instantly (optimistic update)
- Assistant tokens stream without delay
- Model selector switches providers mid-conversation
- Rate limit triggers after 30 requests/minute (check Network tab for 429)
3. Verify Edge Runtime in Vercel dashboard:
Go to Functions → api/chat. Confirm:
- Runtime:
edge - Region: close to your users (or
iad1/sfo1for US) - Invocation duration: < 500ms for first token
4. Check logs for structured errors:
In Vercel Logs, filter by api/chat. You should see JSON lines with model, messageCount, and error fields when something fails.
Step 9: Add observability (optional but recommended)
Production chatbots need visibility into latency, token usage, and error rates. The AI SDK v4 exposes callbacks for this. Update the route:
// Add to streamText options:
onError: (error) => {
console.error('[chat] streaming error:', {
model,
messageCount: messages.length,
error: error instanceof Error ? error.message : String(error),
})
},
onFinish: (result) => {
console.log('[chat] completed', {
model,
usage: result.usage, // { promptTokens, completionTokens, totalTokens }
finishReason: result.finishReason, // 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error'
responseTimeMs: result.responseTimeMs,
})
},
For production, ship these logs to a structured logging platform (Datadog, Logtail, Axiom) or push metrics to Prometheus via a Vercel Edge Middleware. Token usage directly maps to cost — alert on completionTokens spikes.
Step 10: Enable streaming markdown rendering (UX polish)
Raw markdown tokens stream awkwardly. The AI SDK provides a Markdown component that renders incrementally. Install the renderer:
npm install @ai-sdk/ui-utils
Update the message rendering in page.tsx:
import { Markdown } from '@ai-sdk/ui-utils/markdown'
// Inside the messages.map:
{m.role === 'assistant' ? (
<Markdown className="prose prose-sm max-w-none" value={m.content} />
) : (
<div className="whitespace-pre-wrap">{m.content}</div>
)}
The Markdown component uses a streaming-friendly parser that updates the DOM as tokens arrive, avoiding layout shift.
Common failure modes and fixes
| Symptom | Cause | Fix |
|---|---|---|
| First token takes > 2s | Node.js runtime cold start | Confirm export const runtime = 'edge' |
| Stream cuts off at 10s | Vercel Hobby plan timeout | Upgrade to Pro or reduce maxTokens |
| 429 from provider | Shared IP rate limit | Use a gateway with automatic fallback across providers |
ReadableStream error in browser |
Missing polyfill | Edge Runtime includes it; ensure no Node.js polyfills bundled |
| CORS error on preview deployment | Missing Access-Control-Allow-Origin |
Add headers in vercel.json |
Next steps
- Tool calling: Add
toolstostreamTextwith Zod schemas for function calling. - Persistence: Save conversations to Vercel Postgres or a managed database; hydrate
messageson page load. - Authentication: Wrap the API route with NextAuth or Clerk; attach user ID to rate limit keys.
- Evaluations: Log
finishReason === 'content-filter'and sample completions for quality monitoring.
You now have a production-ready chatbot: streaming on the Edge, rate-limited, observable, and deployable with git push. The same pattern scales to multi-turn agents, RAG pipelines, and multi-model routing — just swap the streamText configuration.