Buckets:
| # Building a Next.js AI Chatbot with Vercel AI SDK | |
| In this tutorial, we'll build an in-browser AI chatbot using Next.js, Transformers.js, and the Vercel AI SDK v6. The chatbot runs entirely client-side with WebGPU acceleration — and supports tool calling with human approval. | |
| Useful links: | |
| - [Source code](https://github.com/huggingface/transformers.js-examples/tree/main/next-vercel-ai-sdk-v6-tool-calling) | |
| - [`@browser-ai/transformers-js` docs](https://www.browser-ai.dev/docs/ai-sdk-v6/transformers-js) | |
| - [Vercel AI SDK docs](https://ai-sdk.dev/) | |
| ## Prerequisites | |
| - [Node.js](https://nodejs.org/en/) version 18+ | |
| - [npm](https://www.npmjs.com/) version 9+ | |
| - A browser with WebGPU support (Chrome 113+, Edge 113+, or Firefox/Safari with flags enabled) | |
| ## Step 1: Create the project | |
| Create a new Next.js application: | |
| ```bash | |
| npx create-next-app@latest next-ai-chatbot | |
| cd next-ai-chatbot | |
| ``` | |
| Install the AI and Transformers.js dependencies: | |
| ```bash | |
| npm install ai @ai-sdk/react @browser-ai/transformers-js @huggingface/transformers zod | |
| ``` | |
| ## Step 2: Configure Next.js for browser inference | |
| Transformers.js uses ONNX Runtime under the hood for both browser and server-side (Node.js) inference. In our case we only need the browser runtime so we can tell Next.js to exclude the Node.js-specific packages when bundling for the browser. Update `next.config.ts` | |
| ```typescript | |
| import type { NextConfig } from "next"; | |
| const nextConfig: NextConfig = { | |
| output: "export", // optional: export as a static site | |
| turbopack: {}, | |
| webpack: (config) => { | |
| config.resolve.alias = { | |
| ...config.resolve.alias, | |
| sharp$: false, | |
| "onnxruntime-node$": false, | |
| }; | |
| return config; | |
| }, | |
| }; | |
| export default nextConfig; | |
| ``` | |
| ## Step 3: Create the Web Worker | |
| Running model inference on the main thread would block the UI. The `@browser-ai/transformers-js` package provides a ready-made worker handler that handles all the complexity for you. | |
| Create `src/app/worker.ts`: | |
| ```typescript | |
| import { TransformersJSWorkerHandler } from "@browser-ai/transformers-js"; | |
| const handler = new TransformersJSWorkerHandler(); | |
| self.onmessage = (msg: MessageEvent) => { | |
| handler.onmessage(msg); | |
| }; | |
| ``` | |
| That's it — the handler takes care of model loading, inference, streaming, and communication with the main thread. | |
| ## Step 4: Define the model configuration | |
| Create `src/app/models.ts` to define which models are available. These are ONNX-format models from Hugging Face: | |
| ```typescript | |
| import { WorkerLoadOptions } from "@browser-ai/transformers-js"; | |
| export interface ModelConfig extends Omit { | |
| id: string; | |
| name: string; | |
| supportsWorker?: boolean; | |
| } | |
| export const MODELS: ModelConfig[] = [ | |
| { | |
| id: "onnx-community/Qwen3-0.6B-ONNX", | |
| name: "Qwen3 0.6B", | |
| device: "webgpu", | |
| dtype: "q4f16", | |
| supportsWorker: true, | |
| }, | |
| { | |
| id: "onnx-community/granite-4.0-350m-ONNX-web", | |
| name: "Granite 4.0 350M", | |
| device: "webgpu", | |
| dtype: "fp16", | |
| supportsWorker: true, | |
| }, | |
| ]; | |
| ``` | |
| For tool calling, use reasoning models like Qwen3 which handle multi-step reasoning well, or fine-tuned model specifically for tool-calling capabilities. The `supportsWorker` flag controls whether the model is loaded in a Web Worker for better performance. | |
| ## Step 5: Define tools | |
| Create `src/app/tools.ts` with tools the model can call. Each tool uses [Zod](https://zod.dev/) for input validation: | |
| ```typescript | |
| import { tool } from "ai"; | |
| import z from "zod"; | |
| export const createTools = () => ({ | |
| getCurrentTime: tool({ | |
| description: "Get the current date and time.", | |
| inputSchema: z.object({}), | |
| execute: async () => { | |
| const now = new Date(); | |
| return { | |
| timestamp: now.toISOString(), | |
| date: now.toLocaleDateString("en-US", { | |
| weekday: "long", year: "numeric", month: "long", day: "numeric", | |
| }), | |
| time: now.toLocaleTimeString("en-US", { | |
| hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true, | |
| }), | |
| timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, | |
| }; | |
| }, | |
| }), | |
| randomNumber: tool({ | |
| description: "Generate a random integer between min and max (inclusive).", | |
| inputSchema: z.object({ | |
| min: z.number().describe("The minimum value (inclusive)"), | |
| max: z.number().describe("The maximum value (inclusive)"), | |
| }), | |
| execute: async ({ min, max }) => { | |
| return Math.floor(Math.random() * (Math.floor(max) - Math.ceil(min) + 1)) + Math.ceil(min); | |
| }, | |
| }), | |
| getLocation: tool({ | |
| description: "Get the user's current geographic location.", | |
| inputSchema: z.object({}), | |
| needsApproval: true, // requires user confirmation before executing | |
| execute: async () => { | |
| return new Promise((resolve, reject) => { | |
| navigator.geolocation.getCurrentPosition( | |
| (pos) => resolve({ | |
| latitude: pos.coords.latitude, | |
| longitude: pos.coords.longitude, | |
| }), | |
| (err) => reject(err.message), | |
| ); | |
| }); | |
| }, | |
| }), | |
| }); | |
| ``` | |
| The `getLocation` tool uses `needsApproval: true`, which means the AI SDK will pause execution and wait for the user to approve or reject the tool call before running it. | |
| ## Step 6: Create the chat transport | |
| The Vercel AI SDK's `useChat` hook needs a [transport](https://ai-sdk.dev/docs/ai-sdk-ui/transport) that handles communication with the model. For client-side inference, we implement a custom `ChatTransport`. | |
| Create `src/app/chat-transport.ts`: | |
| ```typescript | |
| import { | |
| ChatTransport, UIMessageChunk, streamText, | |
| convertToModelMessages, ChatRequestOptions, | |
| createUIMessageStream, stepCountIs, | |
| } from "ai"; | |
| import { | |
| TransformersJSLanguageModel, | |
| TransformersUIMessage, | |
| transformersJS, | |
| } from "@browser-ai/transformers-js"; | |
| import { MODELS } from "./models"; | |
| import { createTools } from "./tools"; | |
| export class TransformersChatTransport | |
| implements ChatTransport | |
| { | |
| private model: TransformersJSLanguageModel; | |
| private tools: ReturnType; | |
| constructor() { | |
| const config = MODELS[0]; | |
| this.model = transformersJS(config.id, { | |
| device: config.device, | |
| dtype: config.dtype, | |
| ...(config.supportsWorker | |
| ? { | |
| worker: new Worker(new URL("./worker.ts", import.meta.url), { | |
| type: "module", | |
| }), | |
| } | |
| : {}), | |
| }); | |
| this.tools = createTools(); | |
| } | |
| async sendMessages( | |
| options: { | |
| chatId: string; | |
| messages: TransformersUIMessage[]; | |
| abortSignal: AbortSignal | undefined; | |
| } & { | |
| trigger: "submit-message" | "submit-tool-result" | "regenerate-message"; | |
| messageId: string | undefined; | |
| } & ChatRequestOptions, | |
| ): Promise> { | |
| const { messages, abortSignal } = options; | |
| const prompt = await convertToModelMessages(messages); | |
| return createUIMessageStream({ | |
| execute: async ({ writer }) => { | |
| // Track download progress if the model hasn't been downloaded yet | |
| let downloadProgressId: string | undefined; | |
| const availability = await this.model.availability(); | |
| if (availability !== "available") { | |
| await this.model.createSessionWithProgress( | |
| (progress: number) => { | |
| const percent = Math.round(progress * 100); | |
| if (progress >= 1) { | |
| if (downloadProgressId) { | |
| writer.write({ | |
| type: "data-modelDownloadProgress", | |
| id: downloadProgressId, | |
| data: { | |
| status: "complete", progress: 100, | |
| message: "Model ready!", | |
| }, | |
| }); | |
| } | |
| return; | |
| } | |
| if (!downloadProgressId) { | |
| downloadProgressId = `download-${Date.now()}`; | |
| } | |
| writer.write({ | |
| type: "data-modelDownloadProgress", | |
| id: downloadProgressId, | |
| data: { | |
| status: "downloading", progress: percent, | |
| message: `Downloading model... ${percent}%`, | |
| }, | |
| }); | |
| }, | |
| ); | |
| } | |
| const result = streamText({ | |
| model: this.model, | |
| tools: this.tools, | |
| stopWhen: stepCountIs(5), | |
| messages: prompt, | |
| abortSignal, | |
| }); | |
| writer.merge(result.toUIMessageStream({ sendStart: false })); | |
| }, | |
| }); | |
| } | |
| async reconnectToStream(): Promise | null> { | |
| return null; | |
| } | |
| } | |
| ``` | |
| Key parts of the transport: | |
| - **Availability check**: Determines if the model needs downloading before inference. | |
| - **Progress streaming**: Sends download progress as custom data parts (`data-modelDownloadProgress`) that the UI can render as a progress bar. | |
| - **Tool support**: Passes the tools to `streamText()` so the model can call them. | |
| - **Step limiting**: `stopWhen: stepCountIs(5)` prevents infinite tool-calling loops. | |
| ## Step 7: Build the chat UI | |
| Now wire everything together in your page component. Create `src/app/page.tsx`: | |
| ```tsx | |
| "use client"; | |
| import { useState } from "react"; | |
| import { useChat } from "@ai-sdk/react"; | |
| import { TransformersUIMessage } from "@browser-ai/transformers-js"; | |
| import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai"; | |
| import { TransformersChatTransport } from "./chat-transport"; | |
| export default function ChatPage() { | |
| const [input, setInput] = useState(""); | |
| const { | |
| messages, | |
| sendMessage, | |
| status, | |
| stop, | |
| addToolApprovalResponse, | |
| } = useChat({ | |
| transport: new TransformersChatTransport(), | |
| experimental_throttle: 75, | |
| // Automatically resumes after tool approval responses are submitted | |
| sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, | |
| }); | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (input.trim() && status === "ready") { | |
| sendMessage({ text: input }); | |
| setInput(""); | |
| } | |
| }; | |
| return ( | |
| AI Chatbot | |
| {messages.map((message) => ( | |
| {message.role === "user" ? "You" : "Assistant"}: | |
| {message.parts.map((part, i) => { | |
| switch (part.type) { | |
| case "text": | |
| return {part.text}; | |
| case "data-modelDownloadProgress": | |
| if (!part.data.message) return null; | |
| return ( | |
| {part.data.message} | |
| {part.data.status === "downloading" && ( | |
| )} | |
| ); | |
| default: | |
| // Handle tool parts | |
| if (part.type.startsWith("tool-") && "state" in part) { | |
| if ( | |
| part.state === "approval-requested" && | |
| "approval" in part | |
| ) { | |
| return ( | |
| Tool {part.type.replace("tool-", "")} wants to run. | |
| addToolApprovalResponse({ id: part.approval!.id, approved: true }) | |
| }> | |
| Approve | |
| addToolApprovalResponse({ | |
| id: part.approval!.id, approved: false, | |
| reason: "User denied", | |
| }) | |
| }> | |
| Deny | |
| ); | |
| } | |
| if ("output" in part && part.output) { | |
| return ( | |
| {JSON.stringify(part.output, null, 2)} | |
| ); | |
| } | |
| } | |
| return null; | |
| } | |
| })} | |
| ))} | |
| {status === "submitted" && Thinking...} | |
| setInput(e.target.value)} | |
| placeholder="Ask something..." | |
| style={{ width: "100%", padding: 8 }} | |
| /> | |
| {status === "streaming" ? ( | |
| Stop | |
| ) : ( | |
| Send | |
| )} | |
| ); | |
| } | |
| ``` | |
| The component renders message parts based on their `type`: | |
| - `text` — standard text output from the model. | |
| - `data-modelDownloadProgress` — custom data parts sent by the transport during model download. | |
| - `tool-*` — tool call parts with states like `approval-requested`, `output-available`, etc. | |
| The `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses` option tells `useChat` to automatically resume generation after the user responds to a tool approval request. | |
| ## Step 8: Run the application | |
| Start the development server: | |
| ```bash | |
| npm run dev | |
| ``` | |
| Open your browser and navigate to the URL shown in the terminal. The first time you send a message, the model will be downloaded and cached in the browser. Subsequent visits will load the cached model. | |
| Try prompts like: | |
| - "What time is it?" | |
| - "Generate a random number between 1 and 100" | |
| - "Where am I located?" (this will trigger a tool approval prompt) | |
| ## Next steps | |
| - Add more models and a model selector — see the [full example source](https://github.com/huggingface/transformers.js-examples/tree/main/next-vercel-ai-sdk-v6-tool-calling) for a multi-model implementation with Zustand state management. | |
| - Add a browser compatibility check with `doesBrowserSupportTransformersJS()` and fall back to a server-side route if WebGPU is unavailable. | |
| - Explore the [Vercel AI SDK agents documentation](https://ai-sdk.dev/docs/agents/overview) for more complex agent patterns. | |
| - See the [Vercel AI SDK guide](../integrations/vercel-ai-sdk) for a reference of all supported features (embeddings, vision, transcription, etc.). | |
Xet Storage Details
- Size:
- 14 kB
- Xet hash:
- eadc69fd45a50ce6411bc02bac3d3248e0ea85ce5a3ece8124654a4a6705967e
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.