Building a vercel ai sdk chatbot file uploads feature requires more than a plain text input. In this guide we wire up attachment handling in a Next.js app using the Vercel AI SDK, so users can send images, PDFs, or text files to a multimodal model and get streamed answers back in the same conversation thread.
Step 1: Scaffold the Next.js project
Start from a clean App Router project and add the AI SDK packages. The code below targets AI SDK v3, where ai/react is the client entry point. If you are on v4, swap that import for @ai-sdk/react; the hooks are identical.
npx create-next-app@latest file-chatbot --ts --app --eslint
cd file-chatbot
npm install ai @ai-sdk/openai zod
The @ai-sdk/openai provider gives you the openai model factory. The streamText function and LanguageModel interface stay stable even if you later point at a different inference backend, which matters when you scale beyond a single vendor.
Step 2: Build the client upload UI
The useChat hook manages message state, input, and streaming. We extend it with a file picker that attaches File objects to the message passed to append. The SDK detects attachments and switches the transport from JSON to multipart/form-data automatically.
"use client";
import { useChat } from "ai/react";
import { useRef, useState } from "react";
export function Chat() {
const { messages, append, isLoading } = useChat();
const [files, setFiles] = useState<File[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
async function send() {
const text = inputRef.current?.value ?? "";
if (!text && files.length === 0) return;
await append({
role: "user",
content: text,
// @ts-expect-error experimental field for attachments
experimental_attachments: files,
});
setFiles([]);
if (inputRef.current) inputRef.current.value = "";
}
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}</strong>: {m.content}
</div>
))}
<input ref={inputRef} placeholder="Ask something..." />
<input
type="file"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
<button onClick={send} disabled={isLoading}>Send</button>
</div>
);
}
The experimental_attachments field is the supported mechanism for passing files alongside text. Each entry is a File object. The client creates object URLs locally so the UI can preview before send; the actual bytes go over the wire only on append.
One caveat: useChat stores the entire message history in memory on the client. If a user attaches a 4 MB image, that base64 or object URL stays in the React state for the session. For long conversations, consider stripping old attachment data after the model acknowledges receipt.
Step 3: Capture multipart requests in the route handler
Because the client posts multipart/form-data when attachments are present, your route must parse req.formData() rather than req.json(). The field names are fixed: message carries the text, and experimental_attachments carries the files.
// 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 form = await req.formData();
const text = (form.get("message") as string) ?? "";
const files = form.getAll("experimental_attachments") as File[];
const messages = [
{
role: "user",
content: [
{ type: "text", text },
...(await Promise.all(files.map(fileToPart))),
],
},
];
const result = streamText({
model: openai("gpt-4o"),
messages,
});
return result.toDataStreamResponse();
}
If you later add conversation history, map prior messages from the client (they arrive as a messages JSON field when no attachments are in the current turn). For simplicity this snippet sends a single user turn; extend it by parsing form.get("messages") when files.length === 0.
Step 4: Convert files to model parts
Multimodal models expect typed content parts. Images become { type: "image", image: Buffer | URL }. PDFs or plain text need different handling. The converter below buffers the file and tags the MIME type.
async function fileToPart(file: File) {
if (file.type.startsWith("image/")) {
const buf = Buffer.from(await file.arrayBuffer());
return { type: "image", image: buf, mimeType: file.type };
}
if (file.type === "application/pdf") {
const buf = Buffer.from(await file.arrayBuffer());
return { type: "file", data: buf, mimeType: "application/pdf" };
}
const text = await file.text();
return { type: "text", text: `File ${file.name}:\n${text}` };
}
For images, most vision models accept PNG, JPEG, and WEBP. PDF support is narrower; GPT-4o can ingest PDFs as a binary part, but smaller models cannot. If you target a model without PDF support, extract text with pdf-parse on the server before sending. Never assume the model will read a random binary blob.
Step 5: Choose a model and provider
You need a model that accepts the part types you emit. gpt-4o handles images and PDFs. If you want to avoid juggling multiple API keys or need fallback when a provider is rate-limited, an OpenAI-compatible gateway like n4n.ai exposes one endpoint for 240+ models and automatically fails over to another provider on degradation. The AI SDK can target it by setting baseURL.
import { createOpenAI } from "@ai-sdk/openai";
const gateway = createOpenAI({
baseURL: "https://api.n4n.ai/v1",
apiKey: process.env.GATEWAY_KEY,
});
streamText({ model: gateway("gpt-4o"), messages });
This keeps your vercel ai sdk chatbot file uploads code unchanged while giving you breadth across providers and built-in per-token metering.
Step 6: Render attachments in the message list
The streamed messages from useChat include echoed attachment metadata. Extend the map to show file names and inline images so the user sees what they sent.
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}</strong>
<p>{m.content}</p>
{m.experimental_attachments?.map((a, i) => (
<div key={i}>
{a.contentType?.startsWith("image/") ? (
// @ts-expect-error url may be present
<img src={a.url} alt={a.name} width={200} />
) : (
<a href={a.url}>{a.name}</a>
)}
</div>
))}
</div>
))}
The url field is an object URL created by the browser. It is not a server path. If you reload the page, those local URLs die; persist to storage if you need durable history.
Step 7: Enforce limits and security
Serverless functions have tight memory and time ceilings. A 10 MB file buffered in Buffer.from inside a Vercel function consumes that memory for the whole invocation. Add a size guard before conversion.
const MAX_SIZE = 5 * 1024 * 1024;
for (const f of files) {
if (f.size > MAX_SIZE) {
return new Response("File too large", { status: 413 });
}
}
Reject unexpected MIME types explicitly. Treat file names as untrusted strings; if you ever write them to disk, strip path separators. The model receives only the bytes and type you forward, so a malicious filename cannot traverse your server, but your own logging might.
Step 8: Verify the end-to-end flow
Run the dev server and exercise the path with a real file.
npm run dev
Open http://localhost:3000, pick a small PNG, type “describe this image”, and send. The image should render in the message list and a streamed description should appear within a few seconds.
For a headless check, use curl with a multipart post that mimics the SDK client:
curl -F "message=What is in this image?" \
-F "experimental_attachments=@./sample.png;type=image/png" \
http://localhost:3000/api/chat
The response is a data stream starting with 0:{"role":"assistant". A 413 confirms your size guard. A model error about unsupported content tells you the model id or part shape is wrong.
Step 9: Production considerations
Streaming large files through a serverless function doubles egress and slows cold starts. For real traffic, upload directly to S3 or R2 from the client, then send only the remote URL to the model. The AI SDK accepts image parts with remote URLs, skipping the buffer entirely:
{ type: "image", image: "https://cdn.example.com/uid.png" }
Keep the vercel ai sdk chatbot file uploads feature behind authentication if model calls are billed per token. Log usage at the edge or rely on gateway metering to catch abuse. Once attachments are remote, your route handler becomes a thin passthrough and scales horizontally without memory spikes.
That is the full loop: client picker, multipart post, server conversion, multimodal call, streamed reply, and verified locally and via curl.