Building an AI Chatbot with Gemini and Vercel Serverless Functions

Building an AI Chatbot with Gemini and Vercel Serverless Functions

A couple of months ago, I built an AI chatbot using React, Node.js, and Vercel Serverless Functions, which I have used in my web app. Here’s how I did it: Architecture overview UI component: a React chat widget where user can input and the response would be rendered. Backend: a Vercel serverless function (e.g. api/chat) that validates the request, calls Gemini, and streams the output back to the browser.Reference for Vercel’s serverless function → https://vercel.com/docs/functions How does it work? Browser → Vercel function → Gemini API → Response is streamed back to the browser, chunk by chunk, so the response from the backend would be sent in chunks instead of making the user wait long. Prerequisites Node.js installed locally (Node 18+) A Gemini API key 🔑 from Google AI Studio https://aistudio.google.com/ A Vercel account https://vercel.com/ and the Vercel CLI https://vercel.com/docs/cli npm install @google/genai in your function's project process.env.GOOGLE_API_KEY would contain the Gemini API’s key for local development, and add the same variable in Vercel project's dashboard (Settings → Environment Variables) before deploying so that the API key value will be picked up when the application is hosted. API contract Request POST /api/chat { "message": "How can I improve my resume summary?", "resume": { "name": "...", "experience": [...], "skills": [...] } } Backend (the Vercel serverless function) What does the handler do? validates and sanitizes inputs invokes Gemini'sgenerateContentStream sends output to the browser withres.write() the allowCors wrapper is used restrict requests from different domain. 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) Testing the endpoint before wiring up the UI Before starting to develop the frontend, confirm if the serverless function actually streams chunks using 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 will see text appear incrementally rather than all at once, that's your confirmation the streaming is working end to end. curl output Frontend: reading the response stream Here is the chat widget UI, built in React:UI Widget Below is the function invoked when the user types a prompt and clicks the send button. 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); } } getReader() gives you a ReadableStreamDefaultReader on the response body, and TextDecoder turns each raw chunk of bytes into text. This pattern is less common than a typical fetch().then(res => res.json()) call, so it is worth pausing on if you haven't streamed a fetch response before. Deploying Once the function and UI work locally, you are basically there: We can use the command line to deploy with vercel --prod, or connect the repo in the Vercel dashboard for git-based deploys Make sure GOOGLE_API_KEY is set as an environment variable in the Vercel project settings Update the CORS origin in allowCors and the fetch URL in the UI to point at your deployed function's actual domain Notes CORS for embedded widgets if your widget runs on a different domain than your Vercel app, you must handle the OPTIONS preflight and set Access-Control-Allow-Origin Conclusion Plain-text chunk streaming is a way to make a chatbot feel responsive on Vercel instead of waiting for the entire response from the API. Next steps: add rate limiting add conversation persistence (KV/Redis/Postgres) add an abort button in the widget to cancel generation If you build something, I would love to hear about it. What are you planning to build with Gemini + Vercel? Have a blessed week 😇

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.