大模型
www.freecodecamp.org ·
@Johnson Samuel
使用Gemini和Vercel Serverless构建AI聊天机器人教程
教程介绍如何用React、Node.js和Vercel Serverless Functions构建聊天机器人,重点讲解流式响应技术,提升交互体验。
AI推荐理由 freeCodeCamp教程,详细讲解Gemini与Vercel Serverless构建聊天机器人,内容具体,但非前沿研究。
70
正文
几个月前,我使用React、Node.js和Vercel Serverless Functions构建了一个聊天机器人应用程序,并将其用于我的Web应用buildcv.makeadifference.app。在本教程中,我将逐步介绍我是如何构建它的,从与Google Gemini API通信的后端无服务器函数,到显示AI生成响应的React聊天组件。在本教程结束时,你将了解:如何设置一个Vercel Serverless函数,该函数调用Gemini并将其响应以小文本块的形式流式传输回浏览器。为什么这种方法比等待整个回复一次性返回更能让聊天机器人感觉更快、响应更灵敏。如何构建一个React组件,该组件读取流式响应并在新文本到达时实时更新聊天窗口。我们还将使用curl测试端点,然后再连接UI,并介绍如何将整个内容实际部署到Vercel。所有这些都围绕一种称为纯文本块流的技术。无服务器函数不是等待AI生成完整响应然后一次性将其全部发送回浏览器,而是将响应作为一系列小文本块流式传输,每个块准备好后立即写入浏览器。这就是聊天机器人具有响应性、"打字"感觉的原因,你可能在现代AI聊天界面中见过这种感觉,而不是长时间停顿后完整答案突然出现。我们将涵盖的内容:🧩 架构概述 ✅ 先决条件 📜 API契约 ⚙️ 后端(Vercel Serverless函数) 🧪 在连接UI之前测试端点 🎨 前端:读取响应流 🚀 部署 ⚠️ 关于嵌入式小部件的CORS说明 🎉 结论 🧩 架构概述 该应用程序由两个主要部分组成。UI组件是一个React聊天小部件,用户在其中输入消息,AI的响应在流式传输时渲染回给用户。后端是一个Vercel serverless函数(例如,api/chat),它验证传入请求,调用Gemini,并将输出流式传输回浏览器。你可以在他们的官方文档中阅读更多关于Vercel serverless函数的信息。以下是整个流程从头到尾的工作方式:浏览器向Vercel函数发送请求,Vercel函数调用Gemini API,Gemini的响应逐块流式传输回浏览器。实际上,这意味着后端在可用时将响应以小片段发送到前端,而不是让用户等待整个响应生成后才看到任何内容。✅ 先决条件 在开始之前,请确保你具备以下条件:本地安装Node.js(Node 18或更高版本)来自Google AI Studio的Gemini API密钥🔑 一个Vercel账户和Vercel CLI 在函数项目中安装@google/genai包,可以通过运行npm install @google/genai添加。你还需要将密钥存储在名为process.env.GOOGLE_API_KEY的环境变量中用于本地开发,并在部署前在Vercel项目仪表板(在Settings下,然后Environment Variables)中添加相同的变量,以便应用程序托管时正确获取API密钥。📜 API契约 在深入研究代码之前,值得花点时间理解前端和后端之间的"契约"——换句话说,前端期望发送什么,后端期望接收什么。当用户在聊天小部件中发送消息时,前端向/api/chat发出POST请求,JSON主体包含两件事:用户刚刚输入的消息,以及已加载到应用程序中的简历数据(因为这个特定的聊天机器人充当简历教练)。然后,后端将这两条信息一起使用,以生成与该用户简历真正相关的响应,而不是通用回复。请求:POST /api/chat { "message": "How can I improve my resume summary?", "resume": { "name": "...", "experience": [...], "skills": [...] } } ⚙️ 后端(Vercel Serverless函数) 这是应用程序的核心:一个Vercel serverless函数,接收聊天请求,验证它,将其传递给Gemini,并在生成时将AI的响应流式传输回浏览器。以下是完整的处理程序,我会在你看完整个内容后逐步解释每个部分的作用。const { GoogleGenAI } = require("@google/genai"); const ai = new GoogleGenAI({ apiKey: process.env.GOOGLE_API_KEY, // GOOGLE_API_KEY可以在Vercel中配置 }); const MAX_TEXT_LENGTH = 2000; const MAX_ARRAY_LENGTH = 50; function sanitizeString(input = "") { // 在此处清理输入字符串 } function sanitizeObject(obj = {}) { // 清理你的简历对象 } const allowCors = fn => async (req, res) => { res.setHeader('Access-Control-Allow-Origin', ''); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // ✅ 处理预检请求 if (req.method === 'OPTIONS') { return res.status(200).end(); } // 另一个选项 res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS, POST, PUT, DELETE') res.setHeader( 'Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' ) if (req.method === 'OPTIONS') { res.status(200).end() return } return await fn(req, res) }; const handler = async (req, res) => { if (req.url === '/api/chat' && req.method === 'POST') { const { message, resume } = req.body || {}; const safeMessage = sanitizeString(message); const safeResume = sanitizeObject(resume); if (!safeMessage) { return res.status(400).json({ error: "Invalid message" }); } if (!safeMessage || !safeResume) { return res.status(400).json({ error: 'Missing message or resume data' }); } try { const stream = await ai.models.
generateContentStream({ model: "", contents: ` You are a professional Resume Coach AI. - Always respond clearly, politely, and professionally. - - Resume data: ${JSON.stringify(safeResume, null, 2)} User question: ${safeMessage} `, }); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); for await (const chunk of stream) { const text = chunk.text; if (text) { res.write(text); } } res.end(); } catch (error) { console.error('Gemini Error:', error); res.status(500).json({ error: 'AI request failed' }); } } // 我添加了一个健康检查用于测试处理程序 if (req.url === '/api/chat?type=healthcheck' && req.method === 'GET') { res.status(200).json({ message: 'Hello from the chat endpoint!' }); } } module.exports = allowCors(handler) 现在让我们逐部分分解:设置Gemini客户端:在文件顶部,我们使用存储在GOOGLE_API_KEY环境变量中的API密钥创建一个GoogleGenAI客户端。这个客户端是我们在函数其余部分与Gemini实际通信所使用的。清理输入:在对传入请求做任何操作之前,我们通过sanitizeString和sanitizeObject运行消息和简历。这些函数会剥离任何意外或过大的内容,这样我们就不会在未经检查的情况下将不受信任的用户输入直接传递给AI。处理CORS:allowCors包装函数包裹着我们的处理程序,负责处理跨域资源共享。由于聊天小部件可能嵌入在与Vercel函数不同的域上,我们需要明确允许来自该域的请求,并处理浏览器在实际POST请求之前自动发送的OPTIONS预检请求。验证请求:在处理程序内部,我们检查消息和简历是否都成功通过清理。如果任何一个缺失或无效,我们立即返回400错误,而不是在已知请求错误的情况下浪费一次Gemini调用。调用Gemini并流式传输响应:这是整个教程的关键部分。我们不是调用常规的"生成内容"方法并等待完整响应返回,而是调用generateContentStream,它返回一个异步可迭代对象。我们使用for await...of循环遍历该流,每当新的文本块到达时,我们立即使用res.write(text)将其写入响应。这正是让浏览器在Gemini生成完整答案之前就开始接收文本的原因。错误处理和健康检查:如果在与Gemini通信时出现任何问题,我们捕获错误,记录它,并返回500响应,以便前端知道出了问题。还有一个简单的GET健康检查端点,你可以在测试实际聊天流程之前点击它来确认函数已启动并运行。🧪 在连接UI之前测试端点 在开始构建前端之前,最好确认serverless函数确实按预期流式传输块。你可以使用一个简单的curl请求来做到这一点。curl -N -X POST "https://YOUR_APP.vercel.app/api/chat?type=chat" \ -H "Content-Type: application/json" \ -d '{"message":"Give me 3 resume summary tips","resume":{"name":"Test"}}' -N标志禁用curl的输出缓冲,因此你会看到文本在终端中增量出现,而不是一次性全部出现。这就是在编写任何前端代码之前,端到端流式传输工作的确认。🎨 前端:读取响应流 这是我在React中构建的聊天小部件UI的截图:我们不会在这里从头开始构建该UI。布局、样式和消息列表实际上取决于你和你的设计偏好。我们将涵盖的是实际驱动它的函数:发送按钮背后的代码,它获取用户输入的内容,将其发送到我们的serverless函数,并读取流式响应,以便小部件在AI回复到达时逐段显示,就像你在上面的截图中看到的那样。在深入该函数之前,这里是它所在的壳,以便你看到它在组件中的位置:function ChatWidget({ resume }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); async function sendMessage() { // ...实际函数在下面添加 } return ( {/* 消息列表、输入框和调用sendMessage()的发送按钮 */} ); } 这就是函数所在的壳:消息列表、输入框、加载标志和错误槽的状态。sendMessage在点击发送按钮时触发。这是完整的函数sendMessage,每当用户输入提示并点击发送时运行。这是将上面显示的UI连接到我们刚刚构建的后端的代码。async function sendMessage() { const messageText = input.trim(); if (!messageText || loading) return; const userMessage = { role: "user", text: messageText }; setMessages((message) => [...message, userMessage]); setInput(""); setLoading(true); setError(null); try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: messageText, resume }) }); if (!res.ok) { throw new Error(`Failed to get response: ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let fullText = ""; setMessages((message) => [...message, { role: "assistant", text: "" }]); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value); fullText += chunk; setMessages((message) => { const updated = [...message]; updated[updated.length - 1] = { role: "assistant", text: fullText }; return updated; }); } } catch (err) { setError("Failed to send
message. Please try again."); setMessages((m) => [...m, { role: "assistant", text: "Sorry, I encountered an error. Please try again later." }]); } finally { setLoading(false); } } 以下是逐步发生的事情:首先,当用户点击发送时,我们获取他们输入的消息并立即将其添加到消息数组中,以便它立即显示在聊天中。然后我们向我们的/api/chat端点发送POST请求,包含消息和简历数据。一旦我们收到响应,我们调用res.body.getReader()在响应体上获取一个ReadableStreamDefaultReader,并使用TextDecoder将每个原始字节块转换为可读文本。从那里,我们循环,一次读取一个块并将其附加到不断增长的fullText字符串,并在每次迭代中用该文本更新聊天中的最后一条消息。这个循环正是创建了你在上面截图中看到的"打字"效果:聊天小部件中助手的消息随着新块的到达而逐字可见地增长,而不是一次性弹出。这种模式与你可能习惯的典型fetch().then(res => res.json())调用略有不同,所以如果你以前没有流式传输过fetch响应,值得停下来思考一下。🚀 部署 一旦你确认函数和UI在本地都能正常工作,部署归结为三个步骤:在Vercel中设置环境变量。在你的项目仪表板中,转到Settings,然后Environment Variables,并添加GOOGLE_API_KEY,使用与本地相同的值。更新CORS来源和fetch URL。在后端,将allowCors的Access-Control-Allow-Origin头指向你的真实部署域。在前端,将fetch调用指向相同的已部署函数URL,而不是localhost。部署,使用以下两个选项之一,适合你的工作流程。选项A:从CLI部署 npm install -g vercel # 如果你还没有安装 vercel login vercel --prod vercel login验证你的机器,vercel --prod从你的项目目录构建并直接发布到生产环境。这适合一次性部署,或者当你希望完全控制部署时间时。选项B:通过GitHub集成部署 将你的项目推送到GitHub仓库(如果还没有)。在Vercel仪表板中,点击Add New,然后Project,然后选择你的仓库。Vercel自动检测你的框架设置。确认它们并点击Deploy。从那时起,每次推送到主分支都会触发自动重新部署。我一直更喜欢这个选项,如果你正在积极迭代项目,它更方便,因为你永远不必记得自己运行部署命令。部署完成后,Vercel会为你的应用提供一个实时URL。使用之前的相同curl命令测试它,替换为你的生产URL,以确认流式传输在生产环境中正常工作,然后再认为自己完成。⚠️ 关于嵌入式小部件的CORS说明 如果你计划将此聊天小部件嵌入到与Vercel应用不同域的网站上(例如,将其作为小部件嵌入营销网站,而函数本身部署在其他地方),默认情况下会遇到CORS限制。浏览器会阻止对不同源的请求,除非服务器明确允许。为了正确处理这一点,你的serverless函数需要响应浏览器在实际POST请求之前自动发送的OPTIONS预检请求,并且需要设置Access-Control-Allow-Origin头以匹配你的小部件实际运行的域。这正是上面后端代码中allowCors包装器为你所做的。🎉 结论 纯文本块流是让聊天机器人在Vercel上感觉响应灵敏的原因。因为用户看到响应逐渐出现,他们不会在屏幕上显示任何内容之前一直等待整个答案生成。如果你想继续在此基础上构建,这里有几个自然的下一步可以考虑:添加速率限制以防止端点被滥用,使用KV、Redis或Postgres等添加对话持久性,以便刷新时不会丢失聊天历史,以及向小部件添加停止按钮,以便用户可以取消正在生成的响应。如果你用这种方法构建了什么东西,我很想听听!你打算用Gemini和Vercel构建什么?祝你一周愉快!😇
查看原文(中文正文为 AI 翻译)
A couple of months back, I built a chatbot application using React, Node.js, and Vercel Serverless Functions, which I used in my web app, buildcv.makeadifference.app. In this tutorial, I'll walk you through exactly how I built it, from the backend serverless function that talks to Google's Gemini API, to the React chat widget that displays the AI's response as it's being generated. By the end of this tutorial, you'll understand: How to set up a Vercel Serverless function that calls Gemini and streams its response back to the browser in small text chunks. Why that approach makes a chatbot feel faster and more responsive than waiting for the entire reply to come back at once. How to build a React component that reads that streaming response and updates the chat window in real time as new text arrives. We'll also test the endpoint with curl before wiring up the UI, and we'll cover how to actually deploy the whole thing to Vercel. All of this revolves around a technique called plain-text chunk streaming. Rather than waiting for the AI to generate its entire response and then sending it all back to the browser at once, the serverless function streams the response as a series of small text chunks, writing each one to the browser as soon as it's ready. This is what gives the chatbot that responsive, "typing" feel you've probably seen in modern AI chat interfaces, instead of a long pause followed by the full answer appearing all at once. What We'll Cover: 🧩 Architecture Overview ✅ Prerequisites 📜 API Contract ⚙️ Backend (the Vercel Serverless Function) 🧪 Testing the Endpoint Before Wiring Up the UI 🎨 Frontend: Reading the Response Stream 🚀 Deploying ⚠️ A Note on CORS for Embedded Widgets 🎉 Conclusion 🧩 Architecture Overview The application is built from two main pieces. The UI component is a React chat widget, where the user types a message and the AI's response gets rendered back to them as it streams in. The backend is a Vercel serverless function (for example, api/chat) that validates the incoming request, calls Gemini, and streams the output back to the browser. You can read more about Vercel's serverless functions in their official documentation. Here's how the whole flow works, from start to finish: the browser sends a request to the Vercel function, the Vercel function calls the Gemini API, and Gemini's response is streamed back to the browser chunk by chunk. In practice, this means the backend sends the response to the frontend in small pieces as they become available, rather than making the user wait for the entire response to be generated before they see anything at all. ✅ Prerequisites Before you get started, make sure you have the following in place: Node.js installed locally (Node 18 or later) A Gemini API key 🔑 from Google AI Studio A Vercel account and the Vercel CLI The @google/genai package installed in your function's project, which you can add by running npm install @google/genai You'll also want to store your key in an environment variable called process.env.GOOGLE_API_KEY for local development, and add that same variable in your Vercel project's dashboard (under Settings and then Environment Variables) before deploying, so the API key gets picked up correctly once the application is hosted. 📜 API Contract Before diving into the code, it's worth taking a moment to understand the "contract" between the frontend and backend – in other words, what the frontend is expected to send, and what the backend expects to receive in return. When a user sends a message in the chat widget, the frontend makes a POST request to /api/chat with a JSON body containing two things: the message the user just typed, and the résumé data that's already loaded into the app (since this particular chatbot is acting as a résumé coach). The backend then uses both pieces of information together to generate a response that's actually relevant to that user's résumé, rather than a generic reply. Request: POST /api/chat { "message": "How can I improve my resume summary?", "resume": { "name": "...", "experience": [...], "skills": [...] } } ⚙️ Backend (the Vercel Serverless Function) This is the heart of the application: a single Vercel serverless function that receives the chat request, validates it, passes it along to Gemini, and streams the AI's response back to the browser as it's generated. Below is the full handler, and I'll walk through what each part of it is doing once you've had a look at the whole thing. const { GoogleGenAI } = require("@google/genai"); const ai = new GoogleGenAI({ apiKey: process.env.GOOGLE_API_KEY, // GOOGLE_API_KEY can be configured in Vercel }); const MAX_TEXT_LENGTH = 2000; const MAX_ARRAY_LENGTH = 50; function sanitizeString(input = "") { // sanitize your input string here } function sanitizeObject(obj = {}) { // sanitize your resume object } const allowCors = fn => async (req, res) => { res.setHeader('Access-Control-Allow-Origin', ''); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // ✅ Handle preflight request if (req.method === 'OPTIONS') { return res.status(200).end(); } // another option res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS, POST, PUT, DELETE') res.setHeader( 'Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' ) if (req.method === 'OPTIONS') { res.status(200).end() return } return await fn(req, res) }; const handler = async (req, res) => { if (req.url === '/api/chat' && req.method === 'POST') { const { message, resume } = req.body || {}; const safeMessage = sanitizeString(message); const safeResume = sanitizeObject(resume); if (!safeMessage) { return res.status(400).json({ error: "Invalid message" }); } if (!safeMessage || !safeResume) { return res.status(400).json({ error: 'Missing message or resume data' }); } try { const stream = await ai.models.generateContentStream({ model: "", contents: ` You are a professional Resume Coach AI. - Always respond clearly, politely, and professionally. - - Resume data: ${JSON.stringify(safeResume, null, 2)} User question: ${safeMessage} `, }); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); for await (const chunk of stream) { const text = chunk.text; if (text) { res.write(text); } } res.end(); } catch (error) { console.error('Gemini Error:', error); res.status(500).json({ error: 'AI request failed' }); } } // I have added a health check for testing the handler if (req.url === '/api/chat?type=healthcheck' && req.method === 'GET') { res.status(200).json({ message: 'Hello from the chat endpoint!' }); } } module.exports = allowCors(handler) Now let's break that down piece by piece: Setting up the Gemini client: At the top of the file, we create a GoogleGenAI client using the API key stored in the GOOGLE_API_KEY environment variable. This client is what we'll use to actually talk to Gemini throughout the rest of the function. Sanitizing the input: Before we do anything with the incoming request, we run the message and résumé through sanitizeString and sanitizeObject. These functions strip out anything unexpected or oversized, so we're never passing untrusted user input straight through to the AI without a check first. Handling CORS: The allowCors wrapper function sits around our handler and takes care of Cross-Origin Resource Sharing. Since the chat widget might be embedded on a different domain than the Vercel function itself, we need to explicitly allow requests from that domain and handle the OPTIONS preflight request that browsers automatically send before the real POST request goes through. Validating the request: Inside the handler itself, we check that both message and resume made it through sanitization successfully. If either one is missing or invalid, we return a 400 error right away instead of wasting a call to Gemini on a request we already know is bad. Calling Gemini and streaming the response: This is the key part of the whole tutorial. Instead of calling a regular "generate content" method and waiting for the full response to come back, we call generateContentStream, which returns an async iterable. We loop over that stream with a for await...of loop, and every time a new chunk of text comes in, we immediately write it to the response with res.write(text). This is exactly what lets the browser start receiving text before Gemini has even finished generating the full answer. Error handling and the health check: If anything goes wrong while talking to Gemini, we catch the error, log it, and return a 500 response so the frontend knows something failed. There's also a simple GET health check endpoint you can hit to confirm the function is up and running before you start testing the actual chat flow. 🧪 Testing the Endpoint Before Wiring Up the UI Before you start building out the frontend, it's a good idea to confirm that the serverless function is actually streaming chunks the way you expect. You can do that with a simple curl request. curl -N -X POST "https://YOUR_APP.vercel.app/api/chat?type=chat" \ -H "Content-Type: application/json" \ -d '{"message":"Give me 3 resume summary tips","resume":{"name":"Test"}}' The -N flag disables curl's output buffering, so you'll see the text appear incrementally in your terminal rather than all at once. That's your confirmation that the streaming is working end to end, before you've written a single line of frontend code. 🎨 Frontend: Reading the Response Stream Here's a screenshot of the chat widget UI I built in React: We won't walk through building that UI from scratch here. The layout, styling, and message list are really up to you and your own design preferences. What we will cover is the function that actually powers it: the code behind the send button, which takes what the user typed, sends it off to our serverless function, and reads back the streaming response so the widget can display the AI's reply as it arrives, piece by piece, the way you see it happening in the screenshot above. Before jumping into that function, here's the shell it lives inside, so you can see where it fits in the component: function ChatWidget({ resume }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); async function sendMessage() { // ...actual function is added below } return ( {/* message list, input box, and a send button that calls sendMessage() */} ); } That's the shell the function lives in: state for the message list, the input box, a loading flag, and an error slot. sendMessage is what fires when the send button is clicked. Here's the full function, sendMessage, which runs whenever the user types a prompt and clicks send. This is the piece of code that connects the UI shown above to the backend we just built. async function sendMessage() { const messageText = input.trim(); if (!messageText || loading) return; const userMessage = { role: "user", text: messageText }; setMessages((message) => [...message, userMessage]); setInput(""); setLoading(true); setError(null); try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: messageText, resume }) }); if (!res.ok) { throw new Error(`Failed to get response: ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let fullText = ""; setMessages((message) => [...message, { role: "assistant", text: "" }]); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value); fullText += chunk; setMessages((message) => { const updated = [...message]; updated[updated.length - 1] = { role: "assistant", text: fullText }; return updated; }); } } catch (err) { setError("Failed to send message. Please try again."); setMessages((m) => [...m, { role: "assistant", text: "Sorry, I encountered an error. Please try again later." }]); } finally { setLoading(false); } } Here's what's happening step by step: First, when the user clicks send, we grab their typed message and immediately add it to the messages array so it shows up in the chat right away. Then we send a POST request to our /api/chat endpoint with the message and the résumé data. Once we get a response back, we call res.body.getReader() to get a ReadableStreamDefaultReader on the response body, and we use a TextDecoder to convert each raw chunk of bytes into readable text. From there, we loop, reading one chunk at a time and appending it to a growing fullText string, and on every iteration we update the last message in the chat with that text. That loop is exactly what creates the "typing" effect you see in the screenshot above: the assistant's message in the chat widget visibly grows word by word as new chunks arrive, instead of popping in all at once. This pattern is a bit different from the typical fetch().then(res => res.json()) call you're probably used to, so it's worth pausing on if you haven't streamed a fetch response before. 🚀 Deploying Once you've confirmed that the function and UI both work locally, deploying comes down to three steps: Set your environment variable in Vercel. In your project's dashboard, go to Settings and then Environment Variables and add GOOGLE_API_KEY with the same value you used locally. Update your CORS origin and fetch URL. In the backend, point allowCors's Access-Control-Allow-Origin header at your real deployed domain. In the frontend, point the fetch call at that same deployed function URL instead of localhost. Deploy, using whichever of the two options below fits your workflow. Option A: Deploy from the CLI npm install -g vercel # if you haven't already vercel login vercel --prod vercel login authenticates your machine, and vercel --prod builds and ships straight to production from your project directory. This is a good fit for one-off deploys, or when you want full control over exactly when a deploy happens. Option B: Deploy via GitHub integration Push your project to a GitHub repository (if it isn't already). In the Vercel dashboard, click Add New and then Project, then select your repository. Vercel auto-detects your framework settings. Confirm them and click Deploy. From then on, every push to your main branch triggers an automatic redeploy. I've always preferred this option and it has been more convenient if you're actively iterating on the project, since you never have to remember to run a deploy command yourself. Once the deploy finishes, Vercel will give you a live URL for your app. Test it with the same curl command from earlier, swapping in your production URL, to confirm that streaming is working correctly in production before you consider yourself done. ⚠️ A Note on CORS for Embedded Widgets If you're planning to embed this chat widget on a website that lives on a different domain than your Vercel app (for example, embedding it as a widget on a marketing site while the function itself is deployed elsewhere), you'll run into CORS restrictions by default. Browsers block requests to a different origin unless the server explicitly allows them. To handle this correctly, your serverless function needs to respond to the OPTIONS preflight request that the browser automatically sends before the real POST request, and it needs to set the Access-Control-Allow-Origin header to match the domain your widget is actually running on. That's exactly what the allowCors wrapper in the backend code above is doing for you. 🎉 Conclusion Plain-text chunk streaming is what makes a chatbot feel responsive on Vercel. Because the user sees the response appear gradually, they aren't stuck waiting for the entire answer to be generated before anything shows up on screen. If you'd like to keep building on this, here are a few natural next steps to consider: adding rate limiting to prevent abuse of the endpoint, adding conversation persistence with something like KV, Redis, or Postgres so chat history isn't lost on refresh, and adding a stop button to the widget so users can cancel a response that's already in the middle of generating. If you build something with this approach, I'd love to hear about it! What are you planning to build with Gemini and Vercel? Hope you have a blessed week! 😇