n4nAI

Deploy a Vercel AI SDK chatbot to production

A step-by-step guide to deploying a Vercel AI SDK chatbot to production with Next.js, covering API routes, streaming, edge runtime, and observability.

n4n Team4 min read983 words

Audio narration

Coming soon — every post will get a voice note here.

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 model field in the request body lets the client switch providers without deploying new code.
  • toDataStreamResponse() returns a ReadableStream compatible with the AI SDK’s useChat hook.

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 CoreMessage typing prevents role/content mismatches.
  • Structured error logging includes model and message count for debugging.
  • sendReasoning: true forwards 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: 30 gives 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 Functionsapi/chat. Confirm:

  • Runtime: edge
  • Region: close to your users (or iad1/sfo1 for 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.

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 tools to streamText with Zod schemas for function calling.
  • Persistence: Save conversations to Vercel Postgres or a managed database; hydrate messages on 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.

Tagsvercel-ai-sdkchatbotdeploymentproduction

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building chatbots with vercel ai sdk & next.js posts →