Tutorial Vercel AI SDK: Buat Chatbot AI dengan Next.js
ID | EN

Tutorial Vercel AI SDK: Buat Chatbot AI dengan Next.js

Senin, 29 Des 2025

Vercel AI SDK adalah library yang bikin integrasi AI ke aplikasi web jadi sangat mudah. Dengan SDK ini, kamu bisa bikin chatbot dengan streaming responses, support multiple AI providers, dan fitur advanced seperti tool calling.

Di tutorial ini, saya bakal guide kamu dari setup sampai production-ready chatbot.

Apa itu Vercel AI SDK?

Vercel AI SDK adalah library open-source untuk membangun aplikasi AI. Kelebihannya:

  • Streaming responses - text muncul kata per kata, UX lebih baik
  • Multiple AI providers - OpenAI, Anthropic, Google, dan lainnya
  • React hooks - useChat, useCompletion yang ready pakai
  • Edge-ready - bisa deploy di edge runtime
  • TypeScript first - full type safety
  • Structured outputs - generate JSON dengan schema validation

Setup Project

1. Buat Next.js Project

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

2. Install Dependencies

npm install ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google

SDK-nya modular, jadi kamu cuma perlu install provider yang dipakai.

3. Setup Environment Variables

Buat file .env.local:

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_GENERATIVE_AI_API_KEY=...

Chatbot Pertama dengan useChat

API Route

Buat app/api/chat/route.ts:

import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";

export const maxDuration = 30;

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

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    system: "Kamu adalah assistant yang helpful. Jawab dalam Bahasa Indonesia.",
  });

  return result.toDataStreamResponse();
}

Chat Component

Buat app/page.tsx:

"use client";

import { useChat } from "ai/react";

export default function Chat() {
  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 mb-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`p-4 rounded-lg ${
              message.role === "user"
                ? "bg-blue-500 text-white ml-auto max-w-[80%]"
                : "bg-gray-100 mr-auto max-w-[80%]"
            }`}
          >
            {message.content}
          </div>
        ))}

        {isLoading && (
          <div className="bg-gray-100 p-4 rounded-lg mr-auto">
            <div className="animate-pulse">Thinking...</div>
          </div>
        )}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ketik pesan..."
          className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          disabled={isLoading}
          className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
        >
          Kirim
        </button>
      </form>
    </div>
  );
}

Jalankan npm run dev dan chatbot sudah jalan!

Streaming Responses Deep Dive

Kenapa Streaming Penting?

Tanpa streaming, user harus tunggu seluruh response selesai generate. Dengan streaming, text muncul secara real-time - UX jauh lebih baik.

Cara Kerja Streaming

import { streamText } from "ai";

const result = streamText({
  model: openai("gpt-4o"),
  messages,
});

// Option 1: Data stream (recommended untuk useChat)
return result.toDataStreamResponse();

// Option 2: Text stream (untuk use case lain)
return result.toTextStreamResponse();

Handle Streaming di Client

useChat otomatis handle streaming. Tapi kalau mau manual:

const response = await fetch("/api/chat", {
  method: "POST",
  body: JSON.stringify({ messages }),
});

const reader = response.body?.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader!.read();
  if (done) break;

  const chunk = decoder.decode(value);
  console.log(chunk); // Text chunk
}

Multiple AI Providers

Salah satu kekuatan Vercel AI SDK adalah unified API untuk berbagai providers.

OpenAI

import { openai } from "@ai-sdk/openai";

const result = streamText({
  model: openai("gpt-4o"), // atau "gpt-4o-mini", "gpt-3.5-turbo"
  messages,
});

Anthropic (Claude)

import { anthropic } from "@ai-sdk/anthropic";

const result = streamText({
  model: anthropic("claude-sonnet-4-20250514"), // atau "claude-3-5-haiku-20241022"
  messages,
});

Google (Gemini)

import { google } from "@ai-sdk/google";

const result = streamText({
  model: google("gemini-2.0-flash"), // atau "gemini-1.5-pro"
  messages,
});

Dynamic Provider Selection

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

function getModel(provider: string) {
  switch (provider) {
    case "openai":
      return openai("gpt-4o");
    case "anthropic":
      return anthropic("claude-sonnet-4-20250514");
    case "google":
      return google("gemini-2.0-flash");
    default:
      return openai("gpt-4o-mini");
  }
}

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

  const result = streamText({
    model: getModel(provider),
    messages,
  });

  return result.toDataStreamResponse();
}

useChat Hook Options

useChat punya banyak options yang powerful:

const {
  messages, // Array of messages
  input, // Current input value
  handleInputChange, // Input onChange handler
  handleSubmit, // Form submit handler
  isLoading, // Loading state
  error, // Error state
  reload, // Reload last AI response
  stop, // Stop streaming
  append, // Append message programmatically
  setMessages, // Set messages manually
} = useChat({
  api: "/api/chat", // Custom API endpoint
  id: "unique-chat-id", // Untuk multiple chats
  initialMessages: [], // Pre-populate messages
  body: {
    // Extra data ke API
    userId: "123",
  },
  headers: {
    // Custom headers
    Authorization: "Bearer token",
  },
  onResponse: (response) => {
    // Callback saat response diterima
    console.log("Response received");
  },
  onFinish: (message) => {
    // Callback saat streaming selesai
    console.log("Finished:", message);
  },
  onError: (error) => {
    // Error handling
    console.error("Error:", error);
  },
});

Contoh: Chat dengan Stop Button

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

  return (
    <div>
      {/* Messages */}

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        {isLoading ? (
          <button type="button" onClick={stop}>
            Stop
          </button>
        ) : (
          <button type="submit">Kirim</button>
        )}
      </form>
    </div>
  );
}

Structured Outputs

Generate JSON dengan schema validation menggunakan Zod:

import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";

const result = await generateObject({
  model: openai("gpt-4o"),
  schema: z.object({
    recipe: z.object({
      name: z.string(),
      ingredients: z.array(
        z.object({
          name: z.string(),
          amount: z.string(),
        })
      ),
      steps: z.array(z.string()),
      cookingTime: z.number().describe("Cooking time in minutes"),
    }),
  }),
  prompt: "Generate resep nasi goreng",
});

console.log(result.object);
// { recipe: { name: "Nasi Goreng", ingredients: [...], steps: [...], cookingTime: 15 } }

Streaming Structured Output

import { streamObject } from "ai";

const result = streamObject({
  model: openai("gpt-4o"),
  schema: recipeSchema,
  prompt: "Generate resep nasi goreng",
});

for await (const partialObject of result.partialObjectStream) {
  console.log(partialObject);
  // Object dibangun secara incremental
}

Tool Calling (Function Calling)

Tool calling memungkinkan AI memanggil functions yang kamu define:

import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";

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

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    tools: {
      getWeather: tool({
        description: "Get current weather for a location",
        parameters: z.object({
          location: z.string().describe("City name"),
        }),
        execute: async ({ location }) => {
          // Call weather API
          const weather = await fetchWeather(location);
          return weather;
        },
      }),

      searchProducts: tool({
        description: "Search products in database",
        parameters: z.object({
          query: z.string(),
          category: z.string().optional(),
          maxPrice: z.number().optional(),
        }),
        execute: async ({ query, category, maxPrice }) => {
          const products = await db.products.search({
            query,
            category,
            maxPrice,
          });
          return products;
        },
      }),
    },
    maxSteps: 5, // Allow multiple tool calls
  });

  return result.toDataStreamResponse();
}

Display Tool Results di UI

export default function Chat() {
  const { messages } = useChat();

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.role === "user" ? (
            <div>{message.content}</div>
          ) : (
            <div>
              {message.content}

              {/* Display tool invocations */}
              {message.toolInvocations?.map((tool) => (
                <div key={tool.toolCallId} className="bg-gray-50 p-2 rounded">
                  <div>Tool: {tool.toolName}</div>
                  {tool.state === "result" && (
                    <pre>{JSON.stringify(tool.result, null, 2)}</pre>
                  )}
                </div>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

Conversation History & Persistence

Save ke Database

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

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

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    onFinish: async ({ text }) => {
      // Save messages ke database
      await prisma.message.createMany({
        data: [
          {
            conversationId,
            role: "user",
            content: messages[messages.length - 1].content,
          },
          {
            conversationId,
            role: "assistant",
            content: text,
          },
        ],
      });
    },
  });

  return result.toDataStreamResponse();
}

Load History

// API: GET /api/chat/[id]
export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const messages = await prisma.message.findMany({
    where: { conversationId: params.id },
    orderBy: { createdAt: "asc" },
  });

  return Response.json(messages);
}
// Client
const { messages, setMessages } = useChat({
  id: conversationId,
});

useEffect(() => {
  fetch(`/api/chat/${conversationId}`)
    .then((res) => res.json())
    .then(setMessages);
}, [conversationId]);

Rate Limiting

Protect API dari abuse dengan rate limiting:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute
});

export async function POST(req: Request) {
  // Get user identifier
  const ip = req.headers.get("x-forwarded-for") ?? "anonymous";

  const { success, limit, reset, remaining } = await ratelimit.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(),
      },
    });
  }

  // Process chat...
}

Rate Limit per User

const userId = await getUserId(req); // From auth
const { success } = await ratelimit.limit(`user:${userId}`);

Production Deployment Tips

1. Error Handling

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

    const result = streamText({
      model: openai("gpt-4o"),
      messages,
    });

    return result.toDataStreamResponse();
  } catch (error) {
    console.error("Chat error:", error);

    if (error instanceof Error) {
      if (error.message.includes("rate limit")) {
        return new Response("Rate limit exceeded", { status: 429 });
      }
      if (error.message.includes("invalid api key")) {
        return new Response("Configuration error", { status: 500 });
      }
    }

    return new Response("Internal server error", { status: 500 });
  }
}

2. Timeout Handling

export const maxDuration = 60; // Increase timeout untuk responses panjang

export async function POST(req: Request) {
  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    abortSignal: AbortSignal.timeout(55000), // Abort before edge timeout
  });

  return result.toDataStreamResponse();
}

3. Input Validation

import { z } from "zod";

const requestSchema = z.object({
  messages: z.array(
    z.object({
      role: z.enum(["user", "assistant", "system"]),
      content: z.string().max(10000),
    })
  ),
});

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

  // Process...
}

4. Content Moderation

import { openai } from "@ai-sdk/openai";

async function moderateContent(content: string) {
  const response = await openai.moderations.create({
    input: content,
  });

  return response.results[0].flagged;
}

export async function POST(req: Request) {
  const { messages } = await req.json();
  const lastMessage = messages[messages.length - 1];

  if (await moderateContent(lastMessage.content)) {
    return new Response("Content violates usage policy", { status: 400 });
  }

  // Process...
}

5. Logging & Monitoring

export async function POST(req: Request) {
  const startTime = Date.now();
  const requestId = crypto.randomUUID();

  try {
    const result = streamText({
      model: openai("gpt-4o"),
      messages,
      onFinish: ({ usage }) => {
        console.log({
          requestId,
          duration: Date.now() - startTime,
          promptTokens: usage.promptTokens,
          completionTokens: usage.completionTokens,
        });
      },
    });

    return result.toDataStreamResponse();
  } catch (error) {
    console.error({ requestId, error });
    throw error;
  }
}

Pricing Considerations

Cost per Token

ProviderModelInput (per 1M)Output (per 1M)
OpenAIGPT-4o$2.50$10.00
OpenAIGPT-4o-mini$0.15$0.60
AnthropicClaude 3.5 Sonnet$3.00$15.00
AnthropicClaude 3.5 Haiku$0.80$4.00
GoogleGemini 1.5 Pro$1.25$5.00
GoogleGemini 1.5 Flash$0.075$0.30

Tips Hemat Cost

  1. Pakai model yang tepat - GPT-4o-mini cukup untuk banyak use case
  2. Limit context - Jangan kirim semua history, cukup N messages terakhir
  3. Cache responses - Untuk pertanyaan yang sama
  4. Set max tokens - Limit panjang response
const result = streamText({
  model: openai("gpt-4o-mini"), // Lebih murah
  messages: messages.slice(-10), // Hanya 10 messages terakhir
  maxTokens: 500, // Limit response length
});

Complete Example: Production Chatbot

// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "1 m"),
});

const requestSchema = z.object({
  messages: z
    .array(
      z.object({
        role: z.enum(["user", "assistant", "system"]),
        content: z.string().max(10000),
      })
    )
    .max(50),
});

export const maxDuration = 60;

export async function POST(req: Request) {
  try {
    // Rate limiting
    const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
    const { success } = await ratelimit.limit(ip);
    if (!success) {
      return new Response("Rate limit exceeded", { status: 429 });
    }

    // Validate input
    const body = await req.json();
    const { messages } = requestSchema.parse(body);

    // Stream response
    const result = streamText({
      model: openai("gpt-4o-mini"),
      system: `Kamu adalah assistant yang helpful. 
               Jawab dalam Bahasa Indonesia yang natural.
               Jika tidak tahu, bilang tidak tahu.`,
      messages: messages.slice(-20), // Limit context
      maxTokens: 1000,
      tools: {
        getCurrentTime: tool({
          description: "Get current time",
          parameters: z.object({}),
          execute: async () => new Date().toISOString(),
        }),
      },
    });

    return result.toDataStreamResponse();
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response("Invalid request", { status: 400 });
    }
    console.error("Chat error:", error);
    return new Response("Internal server error", { status: 500 });
  }
}

Kesimpulan

Vercel AI SDK bikin development chatbot jadi jauh lebih mudah:

  1. Setup cepat - Beberapa menit sudah bisa streaming
  2. Unified API - Ganti provider tanpa ubah banyak code
  3. Production-ready - Built-in features untuk scale
  4. Type-safe - Full TypeScript support

Langkah selanjutnya:

Selamat ngoding! 🚀


Ada pertanyaan tentang implementasi Vercel AI SDK? Reach out di Twitter @nayakayp!