<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Vercel - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Vercel - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 06 Oct 2026 09:34:52 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/vercel/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Chatbot with Gemini and Vercel Serverless Functions 🚀  ]]>
                </title>
                <description>
                    <![CDATA[ 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 t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-chatbot-with-gemini-and-vercel-serverless-functions/</link>
                <guid isPermaLink="false">6a9f3c3bbf19ac7f324e6191</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ aitools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatbot development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Johnson Samuel ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:35:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4062fae6-db2f-47b8-add8-30d68a223f5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>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, <a href="http://buildcv.makeadifference.app">buildcv.makeadifference.app</a>.</p>
<p>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.</p>
<p>By the end of this tutorial, you'll understand:</p>
<ol>
<li><p>How to set up a Vercel Serverless function that calls Gemini and streams its response back to the browser in small text chunks.</p>
</li>
<li><p>Why that approach makes a chatbot feel faster and more responsive than waiting for the entire reply to come back at once.</p>
</li>
<li><p>How to build a React component that reads that streaming response and updates the chat window in real time as new text arrives.</p>
</li>
</ol>
<p>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.</p>
<p>All of this revolves around a technique called <strong>plain-text chunk streaming</strong>. 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.</p>
<p>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.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-architecture-overview">🧩 Architecture Overview</a></p>
</li>
<li><p><a href="#heading-prerequisites">✅ Prerequisites</a></p>
</li>
<li><p><a href="#heading-api-contract">📜 API Contract</a></p>
</li>
<li><p><a href="#heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</a></p>
</li>
<li><p><a href="#heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</a></p>
</li>
<li><p><a href="#heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</a></p>
</li>
<li><p><a href="#heading-deploying">🚀 Deploying</a></p>
</li>
<li><p><a href="#heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</a></p>
</li>
<li><p><a href="#heading-conclusion">🎉 Conclusion</a></p>
</li>
</ul>
<h2 id="heading-architecture-overview">🧩 Architecture Overview</h2>
<p>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, <code>api/chat</code>) that validates the incoming request, calls Gemini, and streams the output back to the browser.</p>
<p>You can read more about Vercel's serverless functions in <a href="https://vercel.com/docs/functions">their official documentation</a>.</p>
<p>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.</p>
<p>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.</p>
<h2 id="heading-prerequisites">✅ Prerequisites</h2>
<p>Before you get started, make sure you have the following in place:</p>
<ul>
<li><p>Node.js installed locally (Node 18 or later)</p>
</li>
<li><p>A Gemini API key 🔑 from <a href="https://aistudio.google.com/">Google AI Studio</a></p>
</li>
<li><p>A <a href="https://vercel.com/">Vercel account</a> and the <a href="https://vercel.com/docs/cli">Vercel CLI</a></p>
</li>
<li><p>The <code>@google/genai</code> package installed in your function's project, which you can add by running <code>npm install @google/genai</code></p>
</li>
</ul>
<p>You'll also want to store your key in an environment variable called <code>process.env.GOOGLE_API_KEY</code> 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.</p>
<h2 id="heading-api-contract">📜 API Contract</h2>
<p>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.</p>
<p>When a user sends a message in the chat widget, the frontend makes a POST request to <code>/api/chat</code> 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.</p>
<p><strong>Request:</strong></p>
<pre><code class="language-plaintext">POST /api/chat
{
  "message": "How can I improve my resume summary?",
  "resume": { "name": "...", "experience": [...], "skills": [...] }
}
</code></pre>
<h2 id="heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</h2>
<p>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.</p>
<p>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.</p>
<pre><code class="language-jsx">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 =&gt; async (req, res) =&gt; {

    res.setHeader('Access-Control-Allow-Origin', '&lt;&lt;your web app url goes here&gt;&gt;');
    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) =&gt; {

    if (req.url === '/api/chat' &amp;&amp; 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: "&lt;&lt;GEMINI MODEL GOES HERE&gt;&gt;",
                contents: `
            You are a professional Resume Coach AI.

            - Always respond clearly, politely, and professionally.
            - &lt;&lt;Add descriptive instruction for the prompt based on your requirement&gt;&gt;
            - 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' &amp;&amp; req.method === 'GET') {
        res.status(200).json({ message: 'Hello from the chat endpoint!' });
    }
}

module.exports = allowCors(handler)
</code></pre>
<p>Now let's break that down piece by piece:</p>
<ul>
<li><p><strong>Setting up the Gemini client:</strong> At the top of the file, we create a <code>GoogleGenAI</code> client using the API key stored in the <code>GOOGLE_API_KEY</code> environment variable. This client is what we'll use to actually talk to Gemini throughout the rest of the function.</p>
</li>
<li><p><strong>Sanitizing the input:</strong> Before we do anything with the incoming request, we run the message and résumé through <code>sanitizeString</code> and <code>sanitizeObject</code>. 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.</p>
</li>
<li><p><strong>Handling CORS:</strong> The <code>allowCors</code> 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 <code>OPTIONS</code> preflight request that browsers automatically send before the real <code>POST</code> request goes through.</p>
</li>
<li><p><strong>Validating the request:</strong> Inside the handler itself, we check that both <code>message</code> and <code>resume</code> 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.</p>
</li>
<li><p><strong>Calling Gemini and streaming the response:</strong> 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 <code>generateContentStream</code>, which returns an async iterable. We loop over that stream with a <code>for await...of</code> loop, and every time a new chunk of text comes in, we immediately write it to the response with <code>res.write(text)</code>. This is exactly what lets the browser start receiving text before Gemini has even finished generating the full answer.</p>
</li>
<li><p><strong>Error handling and the health check:</strong> 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 <code>GET</code> health check endpoint you can hit to confirm the function is up and running before you start testing the actual chat flow.</p>
</li>
</ul>
<h2 id="heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</h2>
<p>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.</p>
<pre><code class="language-bash">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"}}'
</code></pre>
<p>The <code>-N</code> 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.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/03f59037-0d2b-49eb-9bb2-87716698d5d2.png" alt="Streaming working" style="display: block;" width="2868" height="492" loading="lazy">

<h2 id="heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</h2>
<p>Here's a screenshot of the chat widget UI I built in React:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/3c726767-0fbc-4784-9ce0-24f9d94ae549.png" alt="3c726767-0fbc-4784-9ce0-24f9d94ae549" style="display: block;" width="636" height="826" loading="lazy">

<p>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.</p>
<p>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.</p>
<p>Before jumping into that function, here's the shell it lives inside, so you can see where it fits in the component:</p>
<pre><code class="language-jsx">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 (
    &lt;div className="chat-widget"&gt;
      {/* message list, input box, and a send button that calls sendMessage() */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>That's the shell the function lives in: state for the message list, the input box, a loading flag, and an error slot. <code>sendMessage</code> is what fires when the send button is clicked.</p>
<p>Here's the full function, <code>sendMessage</code>, 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.</p>
<pre><code class="language-jsx">async function sendMessage() {
    const messageText = input.trim();
    if (!messageText || loading) return;

    const userMessage = { role: "user", text: messageText };
    setMessages((message) =&gt; [...message, userMessage]);
    setInput("");
    setLoading(true);
    setError(null);

    try {
      const res = await fetch("&lt;&lt;YOUR VERCEL SERVERLESS FUNCTION URL GOES HERE&gt;&gt;/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) =&gt; [...message, { role: "assistant", text: "" }]);

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

        const chunk = decoder.decode(value);
        fullText += chunk;

        setMessages((message) =&gt; {
          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) =&gt; [...m, {
        role: "assistant",
        text: "Sorry, I encountered an error. Please try again later."
      }]);
    } finally {
      setLoading(false);
    }
  }
</code></pre>
<p>Here's what's happening step by step:</p>
<p>First, when the user clicks send, we grab their typed message and immediately add it to the <code>messages</code> array so it shows up in the chat right away.</p>
<p>Then we send a <code>POST</code> request to our <code>/api/chat</code> endpoint with the message and the résumé data. Once we get a response back, we call <code>res.body.getReader()</code> to get a <code>ReadableStreamDefaultReader</code> on the response body, and we use a <code>TextDecoder</code> to convert each raw chunk of bytes into readable text.</p>
<p>From there, we loop, reading one chunk at a time and appending it to a growing <code>fullText</code> string, and on every iteration we update the last message in the chat with that text.</p>
<p>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 <code>fetch().then(res =&gt; res.json())</code> call you're probably used to, so it's worth pausing on if you haven't streamed a fetch response before.</p>
<h2 id="heading-deploying">🚀 Deploying</h2>
<p>Once you've confirmed that the function and UI both work locally, deploying comes down to three steps:</p>
<ol>
<li><p><strong>Set your environment variable in Vercel.</strong> In your project's dashboard, go to Settings and then Environment Variables and add <code>GOOGLE_API_KEY</code> with the same value you used locally.</p>
</li>
<li><p><strong>Update your CORS origin and fetch URL.</strong> In the backend, point <code>allowCors</code>'s <code>Access-Control-Allow-Origin</code> header at your real deployed domain. In the frontend, point the <code>fetch</code> call at that same deployed function URL instead of localhost.</p>
</li>
<li><p><strong>Deploy</strong>, using whichever of the two options below fits your workflow.</p>
</li>
</ol>
<h4 id="heading-option-a-deploy-from-the-cli">Option A: Deploy from the CLI</h4>
<pre><code class="language-bash">npm install -g vercel   # if you haven't already
vercel login
vercel --prod
</code></pre>
<p><code>vercel login</code> authenticates your machine, and <code>vercel --prod</code> 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.</p>
<h4 id="heading-option-b-deploy-via-github-integration">Option B: Deploy via GitHub integration</h4>
<ol>
<li><p>Push your project to a GitHub repository (if it isn't already).</p>
</li>
<li><p>In the Vercel dashboard, click <strong>Add New</strong> and then <strong>Project</strong>, then select your repository.</p>
</li>
<li><p>Vercel auto-detects your framework settings. Confirm them and click <strong>Deploy</strong>.</p>
</li>
<li><p>From then on, every push to your main branch triggers an automatic redeploy.</p>
</li>
</ol>
<p>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.</p>
<p>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.</p>
<h2 id="heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</h2>
<p>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.</p>
<p>To handle this correctly, your serverless function needs to respond to the <code>OPTIONS</code> preflight request that the browser automatically sends before the real <code>POST</code> request, and it needs to set the <code>Access-Control-Allow-Origin</code> header to match the domain your widget is actually running on. That's exactly what the <code>allowCors</code> wrapper in the backend code above is doing for you.</p>
<h2 id="heading-conclusion">🎉 Conclusion</h2>
<p>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.</p>
<p>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.</p>
<p>If you build something with this approach, I'd love to hear about it! What are you planning to build with Gemini and Vercel?</p>
<p>Hope you have a blessed week! 😇</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What to Do if Your Site Is About to Hit Vercel's Free Image Optimization Cap ]]>
                </title>
                <description>
                    <![CDATA[ I maintain my own website, and every now and then I upgrade the site to a new version. And I frequently experiment with different technologies and architectures while doing so. Recently, I faced an is ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-to-do-if-your-site-is-about-to-hit-vercels-free-image-optimization-cap/</link>
                <guid isPermaLink="false">6a848de33638e739c70ebce3</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ image optimization  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CDN ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md. Fahim Bin Amin ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 16:52:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/58b79fa2-e0a0-4172-b07f-e3446413eca8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I maintain my own website, and every now and then I upgrade the site to a new version. And I frequently experiment with different technologies and architectures while doing so.</p>
<p>Recently, I faced an issue where Vercel was giving me a warning that I was almost reaching my free tier limit on "Image Optimization - Transformation". After a few trials and errors, I managed to fix that so that it doesn't exceed my free tier unnecessarily.</p>
<p>In this article, I'll explain what the issues were, how I pinpointed the exact cause, and what options I had for resolving them. I'll also walk you through the approach I ultimately chose and how I managed to fix the problem.</p>
<h3 id="heading-so-what-was-the-problem">So What Was the Problem?</h3>
<p>My current website is running on version 5 (Year 2026), which uses a CDN from Cloudflare R2 Object Storage. It also has massive SEO optimization for both web search and AI search.</p>
<p>You can check out the website directly here: <a href="https://fahimbinamin.com">fahimbinamin.com</a>. I'm not sure which version you'll be able to see in the domain when you're visiting the website, but it'll always be the latest version.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/6a478190-2df6-4138-b740-e0331833c0db.png" alt="Fahim's Website (5th version)" style="display: block;" width="2880" height="1800" loading="lazy">

<p>As version 5 of my website is a multi-page website, I tried to include everything about me under one platform. I have a lot of writing published in multiple places, and I wanted to add all of the articles under the same platform. But I also wanted to make sure that their canonical URLs worked well. This would help the AI crawler/bot understand the original published URL/source. So I incorporated this feature in the 5th version of my website.</p>
<p>This website uses Next.js and Next's image optimization protocol properly. Currently, the codebase is closed source and you won't be able to access it anywhere. But I've shared a screenshot so that you can have a glimpse of how it looks now.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/5fbedbc8-a5a1-4d84-8eb2-90efc8b81aeb.png" alt="Fahim's website's (5th version) codebase on GitHub (Closed Source)" style="display: block;" width="2880" height="1800" loading="lazy">

<p>Anyway, to save money, I like to utilize free resources as effectively as possible. For the CDN, I'm using Cloudflare with R2 object storage. All the media you see on my website (images, PDFs, and so on) comes from Cloudflare R2 object storage. It's running via Vercel.</p>
<p>Everything was going smoothly until yesterday, when I noticed an email from Vercel stating that I was approaching my free tier limit for image optimization.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/c777113f-a8a5-41b5-a696-82f7d8ab2e37.png" alt="Warning about Image Optimization" style="display: block;" width="848" height="900" loading="lazy">

<p>I opened the usage panel expecting to see my own project images: profile photos, project screenshots, and gallery frames. Instead, almost every source image listed was hosted on <a href="http://freecodecamp.org"><code>freecodecamp.org</code></a>.</p>
<p>This was confusing for a few seconds because I don't host my images on freeCodeCamp. Then I remembered that I actually link to them!</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<p>I've organized the entire article with sections that can help you jump around if you want or need to read any specific section separately.</p>
<ul>
<li><p><a href="#heading-where-these-images-actually-come-from">Where These Images Actually Come From</a></p>
</li>
<li><p><a href="#heading-confirming-the-scope">Confirming the Scope</a></p>
</li>
<li><p><a href="#heading-two-ways-to-fix-this-issue">Two Ways to Fix This Issue</a></p>
</li>
<li><p><a href="#heading-which-solution-i-chose">Which Solution I Chose</a></p>
</li>
<li><p><a href="#heading-doing-the-migration">Doing the Migration</a></p>
</li>
<li><p><a href="#heading-why-this-is-the-better-fix-long-term">Why This Is the Better Fix Long-Term</a></p>
</li>
<li><p><a href="#heading-what-i-will-do-differently-next-time">What I Will Do Differently Next Time</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-where-these-images-actually-come-from">Where These Images Actually Come From</h2>
<p>I have cross-posted a lot of my writing to freeCodeCamp over the years. When I backfilled my articles into this site's blog archive, I kept the article bodies intact, including the <code>&lt;img&gt;</code> tags pointing at freeCodeCamp's and Hashnode's CDNs (as those were the places where the screenshots had always lived).</p>
<p>The blog renderer wraps every inline image in a <code>BlogImage</code> component:</p>
<pre><code class="language-javascript">import Image from "next/image";
 
export default function BlogImage({ src, alt, caption }) {
  return (
    &lt;figure&gt;
      &lt;Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 768px" /&gt;
      &lt;figcaption&gt;{caption}&lt;/figcaption&gt;
    &lt;/figure&gt;
  );
}
</code></pre>
<p><code>next/image</code> actually doesn't care whether the <code>src</code> points at your own domain or someone else's. As long as the host is listed in <code>images.remotePatterns</code> in <code>next.config.ts</code>, Next.js will run it through Vercel's Image Optimization pipeline in this step, including fetching the original, resizing it for every breakpoint, re-encoding to AVIF/WebP, and caching the result.</p>
<p>I had added <code>cdn-media-0.freecodecamp.org</code>, <code>www.freecodecamp.org</code>, and <code>cdn.hashnode.com</code> to that allowlist months ago in two separate commits. I did that specifically so those backfilled images would render instead of breaking.</p>
<p>That actually worked for me in this case. But it also meant that every one of those images (and every responsive size Next.js generated from each one) counted against my Vercel account's free-tier Image Optimization quota. I don't want my quota to be spent carelessly, since it resets monthly and I don't control how many times a crawler or a page load re-triggers a size variant.</p>
<h2 id="heading-confirming-the-scope">Confirming the Scope</h2>
<p>Before fixing anything, I wanted real numbers instead of a guess. I grepped every post in <code>content/posts/*.mdx</code> for <code>src="..."</code> attributes pointing at the three external hosts:</p>
<pre><code class="language-javascript">src="(https://(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"
</code></pre>
<p>That turned up 691 image references across 33 posts: old screenshots from tutorials going back to 2021, everything from disk-partitioning walkthroughs to CUDA setup guides. All of it was being optimized on someone else's dime (mine) instead of being cached once and served flat. That's what was causing the issue earlier.</p>
<p>When I was looking for solutions, I found two ways that could instantly help me fix the issue quickly.</p>
<h2 id="heading-two-ways-to-fix-this-issue">Two Ways to Fix This Issue</h2>
<p><strong>Option 1: Stop optimizing them.</strong> Pass <code>unoptimized</code> to <code>next/image</code> for any source that isn't my own CDN. Vercel stops touching those requests entirely.</p>
<p>In that case, the browser fetches the image straight from freeCodeCamp, and there's no resizing or AVIF conversion, and zero quota impact. This means that it will be just a small diff that can be done in minutes.</p>
<p><strong>Option 2: Own the images.</strong> Download all 691, re-host them on the Cloudflare R2 bucket I already use for every other image on the site, rewrite the <code>src</code> attributes to point at <code>cdn.fahimbinamin.com</code>, and drop the three external hosts from <code>remotePatterns</code> entirely.</p>
<h2 id="heading-which-solution-i-chose">Which Solution I Chose</h2>
<p>Option 1 was tempting for how little needed to be touched. But it meant permanently giving up responsive sizing and modern formats for a third of my blog archive, and it left the site depending on freeCodeCamp's CDN staying up and those specific URLs never changing.</p>
<p>Option 2 costs more up front but matches how every other image on the site already works. It also keeps the optimization benefit and removes the external dependency completely. So I went with Option 2.</p>
<h2 id="heading-doing-the-migration">Doing the Migration</h2>
<h3 id="heading-step-1-download-everything">Step 1: Download Everything</h3>
<p>I wrote a small Node script that walked every post, matched the same three-host regex, and pulled down each unique URL:</p>
<pre><code class="language-javascript">const HOST_RE =
  /src="(https:\/\/(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"/g;
 
// ...for each match, download to downloaded-images/&lt;slug&gt;/&lt;filename&gt;
// and record oldUrl -&gt; "images/writing/&lt;slug&gt;/&lt;filename&gt;" in url-map.json
</code></pre>
<p>Files were organized by post slug specifically to avoid collisions, since a lot of these screenshots share generic names like <code>2022-01-20_18-50.png</code> across completely different tutorials. Lastly, it was a total of 691 downloads with 0 failures, about 105 MB in total.</p>
<h3 id="heading-step-2-upload-to-r2">Step 2: Upload to R2</h3>
<p>I restructured the download folder to mirror the target key prefix (<code>images/writing/&lt;slug&gt;/...</code>) and uploaded it to the bucket using <a href="https://s3browser.com/">CS Browser</a>. R2 exposes an S3-compatible API and a GUI client handles hundreds of nested files more reliably than dragging a folder through the dashboard.</p>
<h3 id="heading-step-3-verify-before-touching-content">Step 3: Verify Before Touching content</h3>
<p>Before rewriting anything, I spot-checked a couple of the newly uploaded URLs:</p>
<pre><code class="language-shell">curl -s -o /dev/null -w "%{http_code}\n" \
  "https://cdn.fahimbinamin.com/images/writing/automount-a-storage-partition-on-startup-in-linux/HDD-Partition.png"
# 200
</code></pre>
<h3 id="heading-step-4-rewrite-the-mdx">Step 4: Rewrite the MDX</h3>
<p>A second script read <code>url-map.json</code> and replaced every matching <code>src="..."</code> in <code>content/posts/*.mdx</code> with the equivalent <code>cdn.fahimbinamin.com</code> URL:</p>
<pre><code class="language-javascript">content = content.replace(HOST_RE, (match, url) =&gt; {
  const entry = urlMap[url];
  if (!entry) return match; // left untouched, logged for review
  return `src="${CDN_BASE}/${entry.cdnPath}"`;
});
</code></pre>
<p>After the testing, I found out that a total of 33 files changed, 692 replacements (one image was referenced twice in the same post), and 0 were left unmapped.</p>
<h3 id="heading-step-5-clean-up-the-config">Step 5: Clean Up the Config</h3>
<p>With no more <code>&lt;Image&gt;</code> references to the external hosts, I removed all three from <code>images.remotePatterns</code> in <code>next.config.ts</code>:</p>
<pre><code class="language-javascript">// Removed, no longer needed
{ protocol: "https", hostname: "cdn.hashnode.com", pathname: "/**" },
{ protocol: "https", hostname: "cdn-media-0.freecodecamp.org", pathname: "/**" },
{ protocol: "https", hostname: "www.freecodecamp.org", pathname: "/**" },
</code></pre>
<p>I ran a full production build afterward. All 43+ blog posts pre-rendered clean, lint passed, and nothing was broken.</p>
<h2 id="heading-why-this-is-the-better-fix-long-term">Why This Is the Better Fix Long-Term</h2>
<p>The quota problem was really a symptom of a smaller mistake: treating a third-party CDN as if it were infrastructure that I controlled. It rendered fine for months, right up until usage crossed a threshold I wasn't watching closely enough.</p>
<p><code>unoptimized</code> would have made the warning go away without addressing the issue. But I would have kept depending on freeCodeCamp's URLs staying valid indefinitely, with no optimization to show for it either.</p>
<p>Self-hosting means that every image on this site now goes through the same pipeline, and uses the cache rules and CDN, regardless of where the content originally lived. I didn't want anything that could silently break because someone else changed a URL structure.</p>
<h2 id="heading-what-i-will-do-differently-next-time">What I Will Do Differently Next Time</h2>
<p>The two <code>remotePatterns</code> entries for freeCodeCamp and Hashnode were each added months apart, each time because a backfilled post's images were broken and adding the host was the fastest fix.</p>
<p>I never asked myself before, "should this actually be hosted here, or should it live on my own CDN from day one?" That question is cheap to ask before backfilling content and expensive to answer after 691 images and 33 posts are already live pointing outward.</p>
<p>I've written that rule down now, so future backfills download and re-host before a single <code>&lt;img&gt;</code> tag ever points off-domain.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Thank you so much for reading all the way through. I hope that this article will help you if you face the same issue in the future.</p>
<p>To get more content like this, you can follow me on <a href="https://www.linkedin.com/in/fahimfba/">LinkedIn</a> and <a href="https://x.com/Fahim_FBA">X</a>. You can also check <a href="https://www.fahimbinamin.com/">my website</a> and follow me on <a href="https://github.com/FahimFBA">GitHub</a> if you're into open source and development. If you like to watch programming and technology-related videos, then you can <a href="https://www.youtube.com/@FahimAmin">subscribe to my YouTube channel</a> as well.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Build a Support Agent with Vercel AI SDK ]]>
                </title>
                <description>
                    <![CDATA[ Vercel AI SDK is a TypeScript-first toolkit for building AI features. It streamlines text generation, embeddings, and structured outputs. We just posted a course on the freeCodeCamp.org YouTube channel that will teach you to use the Vercel AI SDK to ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-support-agent-with-vercel-ai-sdk/</link>
                <guid isPermaLink="false">694ac4a879c823aae0f86bc9</guid>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Tue, 23 Dec 2025 16:34:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1766507669760/bf3fe42c-d729-4b59-9eca-2677e1b49e2a.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Vercel AI SDK is a TypeScript-first toolkit for building AI features. It streamlines text generation, embeddings, and structured outputs.</p>
<p>We just posted a course on the freeCodeCamp.org YouTube channel that will teach you to use the Vercel AI SDK to create and ship a customer support agent that makes autonomous decisions to either answer questions based on your support docs or search the web in real time.</p>
<p>In this course, you’ll ship a customer support agent that:</p>
<ul>
<li><p>Embeds support docs into a Supabase vector store.</p>
</li>
<li><p>Uses retrieval and web search as tools, selected on-the-fly based on the user’s question.</p>
</li>
<li><p>Classifies intents with structured outputs (via generateObject + Zod).</p>
</li>
<li><p>Answers questions with grounded, trustworthy responses—pulling from your docs when relevant or searching the web in real time when needed</p>
</li>
</ul>
<p>The course covers these topics.</p>
<ul>
<li><p>Explain RAG &amp; embeddings and decide when to use each of them.</p>
</li>
<li><p>Set up Supabase as a vector store: create tables, embed documents, and handle chunking/text splitting for large files.</p>
</li>
<li><p>Implement retrieval with Supabase RPC so your agent can fetch the right context for any question.</p>
</li>
<li><p>Use Vercel AI SDK basics: embeddings and generateText for fast, reliable model calls.</p>
</li>
<li><p>Produce structured outputs with generateObject and Zod to validate and route intents.</p>
</li>
<li><p>Call tools with the AI SDK—define schemas, wire execution, and keep everything type-safe.</p>
</li>
<li><p>Treat retrieval and web search as tools, and compose them into a single agent decision flow.</p>
</li>
<li><p>Use the OpenAI web search tool to pull fresh, real-time information when your docs aren’t enough.</p>
</li>
<li><p>Combine it all into a support agent that chooses the best strategy (retrieve, search, or answer directly) and explains its answers.</p>
</li>
</ul>
<p>Watch the full course <a target="_blank" href="https://youtu.be/WKIjkxxNH0c">on the freeCodeCamp.org YouTube channel</a> (2-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/WKIjkxxNH0c" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
