DEV Community

HK Lee
HK Lee

Posted on • Edited on • Originally published at pockit.tools

Vercel AI SDK Complete Guide: Building Production-Ready AI Chat Apps with Next.js

Building AI-powered applications has never been more accessible. With the Vercel AI SDK, you can create sophisticated streaming chat interfaces, text completion systems, and AI-assisted features in your Next.js apps with remarkably little boilerplate.

But here's the challenge: the SDK is evolving rapidly, documentation is scattered across versions, and most tutorials only scratch the surface. If you've tried integrating OpenAI, Anthropic, or other LLM providers into your web app and found yourself drowning in streaming complexity, token management, and state synchronization—this guide is for you.

In this comprehensive deep-dive, we'll build a production-ready AI chat application from scratch, covering everything from basic hooks to advanced patterns like tool calling, multi-model routing, and rate limiting. By the end, you'll have a rock-solid foundation for any AI feature you want to build.

📌 Version Note: This guide is written for Vercel AI SDK v6.0+ (released late 2025). If you're using an earlier version, some APIs may differ. Check npm info ai version to verify your installed version.


Why Vercel AI SDK?

Before diving into code, let's understand why the Vercel AI SDK has become the go-to choice for AI integration in React applications.

The Streaming Problem

When you call an LLM API directly, you get a response only after the entire generation is complete. For a 500-token response, that's 5-10 seconds of waiting. Users hate this.

// The naive approach - terrible UX
const response = await openai.chat.completions.create({
  model: 'gpt-4',
  messages: [{ role: 'user', content: 'Explain quantum computing' }],
});
// User stares at loading spinner for 8 seconds...
console.log(response.choices[0].message.content);
Enter fullscreen mode Exit fullscreen mode

Streaming solves this by sending tokens as they're generated. But implementing proper streaming in React is surprisingly complex:

  • Managing ReadableStream from the API
  • Parsing SSE (Server-Sent Events) or newline-delimited JSON
  • Updating React state without causing re-render waterfalls
  • Handling abort signals for cancellation
  • Managing loading, error, and completion states
  • Synchronizing client and server state

The Vercel AI SDK abstracts all of this into simple, declarative hooks.

Provider Agnostic Architecture

One of the SDK's killer features is its unified interface across providers:

import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';

// Same function signature, different providers
const result = await generateText({
  model: openai('gpt-4-turbo'),
  // or: model: anthropic('claude-3-opus'),
  // or: model: google('gemini-pro'),
  prompt: 'Explain quantum computing',
});
Enter fullscreen mode Exit fullscreen mode

Switch providers with a single line change. No refactoring required.


Core Concepts: AI SDK Architecture

The Vercel AI SDK is split into three main packages:

1. AI SDK Core (ai)

The foundation package providing:

  • generateText() - Generate text with full result
  • streamText() - Stream text generation
  • generateObject() - Generate structured JSON
  • streamObject() - Stream structured JSON generation
  • embed() - Generate embeddings
  • embedMany() - Batch embeddings

2. AI SDK UI (@ai-sdk/react)

React hooks for building UIs:

  • useChat() - Full chat interface management
  • useCompletion() - Single-turn text completion
  • useObject() - Streaming structured data
  • useAssistant() - OpenAI Assistants API integration

3. Provider Packages

Model implementations:

  • @ai-sdk/openai - OpenAI, Azure OpenAI
  • @ai-sdk/anthropic - Claude models
  • @ai-sdk/google - Gemini models
  • @ai-sdk/mistral - Mistral AI
  • @ai-sdk/amazon-bedrock - AWS Bedrock
  • And many community providers...

Setting Up: Project Bootstrap

Let's create a production-ready project structure:

npx create-next-app@latest ai-chat-app --typescript --tailwind --app
cd ai-chat-app

# Install AI SDK packages
npm install ai @ai-sdk/openai @ai-sdk/anthropic

# Optional: UI components
npm install @radix-ui/react-scroll-area lucide-react
Enter fullscreen mode Exit fullscreen mode

Environment Setup

# .env.local
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
Enter fullscreen mode Exit fullscreen mode

Project Structure

src/
├── app/
│   ├── api/
│   │   └── chat/
│   │       └── route.ts      # Chat API endpoint
│   ├── page.tsx              # Main chat UI
│   └── layout.tsx
├── components/
│   ├── chat/
│   │   ├── ChatContainer.tsx
│   │   ├── MessageList.tsx
│   │   ├── MessageBubble.tsx
│   │   └── ChatInput.tsx
│   └── ui/
│       └── Button.tsx
├── lib/
│   ├── ai/
│   │   ├── models.ts         # Model configurations
│   │   └── prompts.ts        # System prompts
│   └── utils.ts
└── types/
    └── chat.ts
Enter fullscreen mode Exit fullscreen mode

Building the Chat API: Server-Side Implementation

The API route is where the magic happens. Let's build a robust chat endpoint:

Basic Chat Route

// src/app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

export const runtime = 'edge'; // Enable edge runtime for lower latency

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4-turbo'),
    messages,
    system: `You are a helpful AI assistant. Be concise and clear in your responses.`,
  });

  return result.toDataStreamResponse();
}
Enter fullscreen mode Exit fullscreen mode

That's it for a basic chat! But production apps need more...

Production-Ready Chat Route

// src/app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { streamText, convertToModelMessages } from 'ai';
import { z } from 'zod';

export const runtime = 'edge';
export const maxDuration = 30; // Maximum execution time

// Request validation schema
const chatRequestSchema = z.object({
  messages: z.array(z.object({
    role: z.enum(['user', 'assistant', 'system']),
    content: z.string(),
  })),
  model: z.enum(['gpt-4-turbo', 'claude-3-opus']).default('gpt-4-turbo'),
  temperature: z.number().min(0).max(2).default(0.7),
});

// Model registry
const models = {
  'gpt-4-turbo': openai('gpt-4-turbo'),
  'claude-3-opus': anthropic('claude-3-opus-20240229'),
};

const SYSTEM_PROMPT = `You are an expert AI assistant specializing in software development.

Guidelines:
- Provide accurate, well-structured responses
- Include code examples when relevant
- Cite sources when making factual claims
- Admit uncertainty rather than guessing
- Keep responses concise but comprehensive`;

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const { messages, model, temperature } = chatRequestSchema.parse(body);

    // Rate limiting check (implement your own logic)
    const clientIP = req.headers.get('x-forwarded-for') || 'unknown';
    // await checkRateLimit(clientIP);

    const result = streamText({
      model: models[model],
      messages: await convertToModelMessages(messages),
      system: SYSTEM_PROMPT,
      temperature,
      maxTokens: 4096,
      // Abort signal for client disconnection
      abortSignal: req.signal,
      // Callbacks for logging/analytics
      onFinish: async ({ text, usage }) => {
        console.log(`Completed: ${usage.totalTokens} tokens`);
        // await logUsage(usage);
      },
    });

    return result.toDataStreamResponse();
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response(JSON.stringify({ error: 'Invalid request', details: error.errors }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    console.error('Chat API Error:', error);
    return new Response(JSON.stringify({ error: 'Internal server error' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Building the Chat UI: useChat Hook Deep Dive

The useChat hook is the heart of the client-side implementation:

Basic Usage

// src/app/page.tsx
'use client';

import { useChat } from '@ai-sdk/react';

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`p-4 rounded-lg ${
              message.role === 'user' 
                ? 'bg-blue-100 ml-auto max-w-[80%]' 
                : 'bg-gray-100 mr-auto max-w-[80%]'
            }`}
          >
            <p className="whitespace-pre-wrap">{message.content}</p>
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2 pt-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type your message..."
          className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
        >
          {isLoading ? 'Sending...' : 'Send'}
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced useChat Configuration

'use client';

import { useChat, Message } from '@ai-sdk/react';
import { useState, useCallback, useRef, useEffect } from 'react';

export default function AdvancedChatPage() {
  const [selectedModel, setSelectedModel] = useState('gpt-4-turbo');
  const scrollRef = useRef<HTMLDivElement>(null);

  const {
    messages,
    input,
    setInput,
    handleInputChange,
    handleSubmit,
    isLoading,
    error,
    reload,
    stop,
    append,
    setMessages,
  } = useChat({
    api: '/api/chat',

    // Send additional data with each request
    body: {
      model: selectedModel,
      temperature: 0.7,
    },

    // Initial messages (e.g., from database)
    initialMessages: [],

    // Generate unique IDs
    generateId: () => crypto.randomUUID(),

    // Called when response starts streaming
    onResponse: (response) => {
      if (!response.ok) {
        console.error('Response error:', response.status);
      }
    },

    // Called when streaming completes
    onFinish: (message) => {
      console.log('Message completed:', message.id);
      // Save to database, analytics, etc.
    },

    // Called on error
    onError: (error) => {
      console.error('Chat error:', error);
    },
  });

  // Auto-scroll to bottom
  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages]);

  // Programmatic message append
  const sendQuickMessage = useCallback((content: string) => {
    append({ role: 'user', content });
  }, [append]);

  // Clear chat history
  const clearChat = useCallback(() => {
    setMessages([]);
  }, [setMessages]);

  // Retry last message
  const retryLast = useCallback(() => {
    if (messages.length >= 2) {
      reload();
    }
  }, [messages, reload]);

  return (
    <div className="flex flex-col h-screen max-w-3xl mx-auto">
      {/* Header with controls */}
      <header className="flex items-center justify-between p-4 border-b">
        <h1 className="text-xl font-bold">AI Chat</h1>
        <div className="flex items-center gap-2">
          <select
            value={selectedModel}
            onChange={(e) => setSelectedModel(e.target.value)}
            className="p-2 border rounded"
          >
            <option value="gpt-4-turbo">GPT-4 Turbo</option>
            <option value="claude-3-opus">Claude 3 Opus</option>
          </select>
          <button onClick={clearChat} className="p-2 text-gray-500 hover:text-gray-700">
            Clear
          </button>
        </div>
      </header>

      {/* Messages */}
      <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 && (
          <div className="text-center text-gray-500 mt-8">
            <p className="text-lg mb-4">How can I help you today?</p>
            <div className="flex flex-wrap justify-center gap-2">
              {['Explain React Server Components', 'Debug my code', 'Write a function'].map((prompt) => (
                <button
                  key={prompt}
                  onClick={() => sendQuickMessage(prompt)}
                  className="px-4 py-2 bg-gray-100 rounded-full hover:bg-gray-200 text-sm"
                >
                  {prompt}
                </button>
              ))}
            </div>
          </div>
        )}

        {messages.map((message) => (
          <MessageBubble key={message.id} message={message} />
        ))}

        {isLoading && (
          <div className="flex items-center gap-2 text-gray-500">
            <div className="animate-pulse"></div>
            <span>AI is thinking...</span>
            <button onClick={stop} className="text-red-500 text-sm">
              Stop
            </button>
          </div>
        )}

        {error && (
          <div className="p-4 bg-red-50 text-red-600 rounded-lg">
            <p>Error: {error.message}</p>
            <button onClick={retryLast} className="text-sm underline">
              Retry
            </button>
          </div>
        )}
      </div>

      {/* Input */}
      <form onSubmit={handleSubmit} className="p-4 border-t">
        <div className="flex gap-2">
          <input
            value={input}
            onChange={handleInputChange}
            placeholder="Type your message..."
            className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
            disabled={isLoading}
          />
          <button
            type="submit"
            disabled={isLoading || !input.trim()}
            className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
          >
            Send
          </button>
        </div>
      </form>
    </div>
  );
}

function MessageBubble({ message }: { message: Message }) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`max-w-[80%] p-4 rounded-2xl ${
          isUser
            ? 'bg-blue-500 text-white rounded-br-md'
            : 'bg-gray-100 text-gray-900 rounded-bl-md'
        }`}
      >
        <p className="whitespace-pre-wrap">{message.content}</p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tool Calling: Extending AI Capabilities

One of the most powerful features of modern LLMs is tool calling (function calling). The AI SDK makes this seamless:

Defining Tools

// src/lib/ai/tools.ts
import { z } from 'zod';
import { tool } from 'ai';

export const weatherTool = tool({
  description: 'Get the current weather for a location',
  parameters: z.object({
    location: z.string().describe('City name, e.g., "San Francisco"'),
    unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
  }),
  execute: async ({ location, unit }) => {
    // In production, call a real weather API
    const temp = Math.floor(Math.random() * 30) + 5;
    return {
      location,
      temperature: temp,
      unit,
      condition: 'Partly cloudy',
    };
  },
});

export const searchTool = tool({
  description: 'Search the web for current information',
  parameters: z.object({
    query: z.string().describe('The search query'),
  }),
  execute: async ({ query }) => {
    // In production, use a search API
    return {
      results: [
        { title: `Result for: ${query}`, url: 'https://example.com' },
      ],
    };
  },
});

export const calculatorTool = tool({
  description: 'Perform mathematical calculations',
  parameters: z.object({
    expression: z.string().describe('Mathematical expression, e.g., "2 + 2 * 3"'),
  }),
  execute: async ({ expression }) => {
    try {
      // WARNING: eval is dangerous in production - use a proper math parser
      const result = Function(`"use strict"; return (${expression})`)();
      return { expression, result };
    } catch {
      return { expression, error: 'Invalid expression' };
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

Using Tools in the API Route

// src/app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { weatherTool, searchTool, calculatorTool } from '@/lib/ai/tools';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4-turbo'),
    messages,
    tools: {
      weather: weatherTool,
      search: searchTool,
      calculator: calculatorTool,
    },
    // Allow multiple tool calls in sequence
    maxSteps: 5,
  });

  return result.toDataStreamResponse();
}
Enter fullscreen mode Exit fullscreen mode

Handling Tool Results in UI

'use client';

import { useChat } from '@ai-sdk/react';

export default function ChatWithTools() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.role === 'user' && <UserMessage content={message.content} />}

          {message.role === 'assistant' && (
            <div>
              {/* Display tool invocations */}
              {message.toolInvocations?.map((tool) => (
                <ToolCard key={tool.toolCallId} tool={tool} />
              ))}

              {/* Display text content */}
              {message.content && <AssistantMessage content={message.content} />}
            </div>
          )}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

function ToolCard({ tool }: { tool: any }) {
  return (
    <div className="p-3 my-2 bg-purple-50 border border-purple-200 rounded-lg">
      <div className="text-sm font-medium text-purple-700">
        🔧 {tool.toolName}
      </div>
      {tool.state === 'result' && (
        <pre className="mt-2 text-xs bg-white p-2 rounded overflow-x-auto">
          {JSON.stringify(tool.result, null, 2)}
        </pre>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Structured Output: generateObject and useObject

Sometimes you need the AI to return structured data, not just text:

Server-Side Structured Generation

// src/app/api/analyze/route.ts
import { openai } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import { z } from 'zod';

const sentimentSchema = z.object({
  sentiment: z.enum(['positive', 'negative', 'neutral']),
  confidence: z.number().min(0).max(1),
  keywords: z.array(z.string()),
  summary: z.string(),
});

export async function POST(req: Request) {
  const { text } = await req.json();

  const { object } = await generateObject({
    model: openai('gpt-4-turbo'),
    schema: sentimentSchema,
    prompt: `Analyze the sentiment of this text: "${text}"`,
  });

  return Response.json(object);
}
Enter fullscreen mode Exit fullscreen mode

Client-Side Streaming Objects

'use client';

import { useObject } from '@ai-sdk/react';
import { z } from 'zod';

const recipeSchema = z.object({
  name: z.string(),
  ingredients: z.array(z.object({
    item: z.string(),
    amount: z.string(),
  })),
  steps: z.array(z.string()),
  prepTime: z.number(),
  cookTime: z.number(),
});

export default function RecipeGenerator() {
  const { object, submit, isLoading } = useObject({
    api: '/api/generate-recipe',
    schema: recipeSchema,
  });

  return (
    <div>
      <button onClick={() => submit({ dish: 'chocolate cake' })}>
        Generate Recipe
      </button>

      {isLoading && <p>Generating...</p>}

      {object && (
        <div>
          <h2>{object.name}</h2>

          {object.ingredients && (
            <ul>
              {object.ingredients.map((ing, i) => (
                <li key={i}>{ing.amount} {ing.item}</li>
              ))}
            </ul>
          )}

          {object.steps && (
            <ol>
              {object.steps.map((step, i) => (
                <li key={i}>{step}</li>
              ))}
            </ol>
          )}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Production Patterns: Rate Limiting, Caching, and Error Handling

Rate Limiting with Upstash Redis

// src/lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const rateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
  analytics: true,
});

// Usage in API route
export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
  const { success, limit, reset, remaining } = await rateLimiter.limit(ip);

  if (!success) {
    return new Response('Rate limit exceeded', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
        'X-RateLimit-Reset': reset.toString(),
      },
    });
  }

  // ... continue with chat logic
}
Enter fullscreen mode Exit fullscreen mode

Response Caching for Common Queries

// src/lib/cache.ts
import { Redis } from '@upstash/redis';
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import crypto from 'crypto';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

function hashPrompt(prompt: string): string {
  return crypto.createHash('sha256').update(prompt).digest('hex');
}

export async function getCachedOrGenerate(prompt: string): Promise<string> {
  const cacheKey = `ai:${hashPrompt(prompt)}`;

  // Check cache
  const cached = await redis.get<string>(cacheKey);
  if (cached) {
    console.log('Cache hit!');
    return cached;
  }

  // Generate new response
  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt,
  });

  // Cache for 1 hour
  await redis.set(cacheKey, text, { ex: 3600 });

  return text;
}
Enter fullscreen mode Exit fullscreen mode

Comprehensive Error Handling

// src/lib/ai/error-handling.ts
import { AIError } from 'ai';

export function handleAIError(error: unknown): Response {
  console.error('AI Error:', error);

  if (error instanceof AIError) {
    switch (error.code) {
      case 'rate_limit_exceeded':
        return new Response(
          JSON.stringify({
            error: 'Rate limit exceeded',
            message: 'Please wait a moment before trying again.',
            retryAfter: 60,
          }),
          { status: 429 }
        );

      case 'context_length_exceeded':
        return new Response(
          JSON.stringify({
            error: 'Message too long',
            message: 'Please shorten your message or start a new conversation.',
          }),
          { status: 400 }
        );

      case 'invalid_api_key':
        return new Response(
          JSON.stringify({
            error: 'Configuration error',
            message: 'AI service is temporarily unavailable.',
          }),
          { status: 503 }
        );

      default:
        return new Response(
          JSON.stringify({
            error: 'AI Error',
            message: error.message,
          }),
          { status: 500 }
        );
    }
  }

  return new Response(
    JSON.stringify({
      error: 'Internal Error',
      message: 'An unexpected error occurred.',
    }),
    { status: 500 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Multi-Model Routing: Smart Provider Selection

For production apps, you might want to route requests to different models based on the task:

// src/lib/ai/router.ts
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';
import { streamText } from 'ai';

type TaskType = 'code' | 'creative' | 'analysis' | 'general';

function detectTaskType(message: string): TaskType {
  const codeKeywords = ['code', 'function', 'debug', 'implement', 'typescript', 'python'];
  const creativeKeywords = ['write', 'story', 'creative', 'imagine', 'poem'];
  const analysisKeywords = ['analyze', 'compare', 'evaluate', 'research', 'explain'];

  const lowerMessage = message.toLowerCase();

  if (codeKeywords.some(k => lowerMessage.includes(k))) return 'code';
  if (creativeKeywords.some(k => lowerMessage.includes(k))) return 'creative';
  if (analysisKeywords.some(k => lowerMessage.includes(k))) return 'analysis';
  return 'general';
}

function selectModel(taskType: TaskType) {
  switch (taskType) {
    case 'code':
      return anthropic('claude-3-opus-20240229'); // Best for coding
    case 'creative':
      return openai('gpt-4-turbo'); // Great for creative writing
    case 'analysis':
      return google('gemini-1.5-pro'); // Good for long-context analysis
    default:
      return openai('gpt-4-turbo'); // General purpose
  }
}

export async function routedChat(messages: any[]) {
  const lastUserMessage = messages.filter(m => m.role === 'user').pop();
  const taskType = detectTaskType(lastUserMessage?.content ?? '');
  const model = selectModel(taskType);

  console.log(`Routing to ${taskType} model`);

  return streamText({
    model,
    messages,
  });
}
Enter fullscreen mode Exit fullscreen mode

Testing AI Applications

Testing AI applications requires a different approach:

// __tests__/chat.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ChatPage from '@/app/page';

// Mock the useChat hook
vi.mock('@ai-sdk/react', () => ({
  useChat: () => ({
    messages: [
      { id: '1', role: 'user', content: 'Hello' },
      { id: '2', role: 'assistant', content: 'Hi there!' },
    ],
    input: '',
    handleInputChange: vi.fn(),
    handleSubmit: vi.fn(),
    isLoading: false,
    error: null,
  }),
}));

describe('ChatPage', () => {
  it('renders messages correctly', () => {
    render(<ChatPage />);
    expect(screen.getByText('Hello')).toBeInTheDocument();
    expect(screen.getByText('Hi there!')).toBeInTheDocument();
  });

  it('disables send button when loading', async () => {
    vi.mocked(useChat).mockReturnValue({
      // ... other props
      isLoading: true,
    } as any);

    render(<ChatPage />);
    expect(screen.getByRole('button', { name: /send/i })).toBeDisabled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Deployment Considerations

Vercel Deployment

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['@ai-sdk/openai'],
  },
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

Environment Variables on Vercel

# Set these in Vercel Dashboard > Settings > Environment Variables
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...
Enter fullscreen mode Exit fullscreen mode

Edge Runtime Considerations

  • Edge functions have a 25MB size limit
  • No Node.js APIs (use Web APIs instead)
  • Limited execution time (30s on Vercel)
  • Perfect for low-latency AI responses

Conclusion

The Vercel AI SDK dramatically simplifies building AI-powered applications. We've covered:

  • Core concepts: Streaming, provider architecture, hooks
  • useChat deep dive: Full configuration, state management, UI patterns
  • Tool calling: Extending AI capabilities with custom functions
  • Structured output: Type-safe AI responses with schemas
  • Production patterns: Rate limiting, caching, error handling
  • Multi-model routing: Smart provider selection
  • Testing and deployment: Best practices for production

The key to successful AI integration isn't just using the right tools—it's understanding the patterns that make applications reliable, performant, and user-friendly.

Quick Reference

// Basic chat
const { messages, input, handleSubmit, isLoading } = useChat();

// With options
const chat = useChat({
  api: '/api/chat',
  body: { model: 'gpt-4' },
  onFinish: (message) => saveToDb(message),
  onError: (error) => toast.error(error.message),
});

// Programmatic control
chat.append({ role: 'user', content: 'Hello' });
chat.reload();
chat.stop();
chat.setMessages([]);
Enter fullscreen mode Exit fullscreen mode

Start simple, iterate based on your needs, and don't forget to monitor token usage—your wallet will thank you.


Building something cool with the Vercel AI SDK? The ecosystem is growing rapidly, with new providers and features launching regularly. Keep an eye on the official documentation and GitHub releases to stay up to date with the latest capabilities.


Speed Tip: Read the original post on the Pockit Blog.

Tired of slow cloud tools? Pockit.tools runs entirely in your browser. Get the Extension now for instant, zero-latency access to essential dev tools.

Top comments (2)

Collapse
 
abdullahjan profile image
Abdullah Jan

Mention the version of AI-SDK you are writing about.

Collapse
 
pockit_tools profile image
HK Lee

Thanks for the feedback! Great catch. 👍

I've updated the article to specify AI SDK v6.0+ compatibility and fixed the code to use the current API:

Added version callout at the top
Updated convertToCoreMessages() → convertToModelMessages() (renamed in v6)
Appreciate the suggestion — always good to have versioned docs! 🙏