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 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.
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.
So What Was the Problem?
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.
You can check out the website directly here: fahimbinamin.com. 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.
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.
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.
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.
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.
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 freecodecamp.org.
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!
Table of Contents
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.
Where These Images Actually Come From
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 <img> tags pointing at freeCodeCamp's and Hashnode's CDNs (as those were the places where the screenshots had always lived).
The blog renderer wraps every inline image in a BlogImage component:
import Image from "next/image";
export default function BlogImage({ src, alt, caption }) {
return (
<figure>
<Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 768px" />
<figcaption>{caption}</figcaption>
</figure>
);
}
next/image actually doesn't care whether the src points at your own domain or someone else's. As long as the host is listed in images.remotePatterns in next.config.ts, 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.
I had added cdn-media-0.freecodecamp.org, www.freecodecamp.org, and cdn.hashnode.com to that allowlist months ago in two separate commits. I did that specifically so those backfilled images would render instead of breaking.
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.
Confirming the Scope
Before fixing anything, I wanted real numbers instead of a guess. I grepped every post in content/posts/*.mdx for src="..." attributes pointing at the three external hosts:
src="(https://(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"
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.
When I was looking for solutions, I found two ways that could instantly help me fix the issue quickly.
Two Ways to Fix This Issue
Option 1: Stop optimizing them. Pass unoptimized to next/image for any source that isn't my own CDN. Vercel stops touching those requests entirely.
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.
Option 2: Own the images. Download all 691, re-host them on the Cloudflare R2 bucket I already use for every other image on the site, rewrite the src attributes to point at cdn.fahimbinamin.com, and drop the three external hosts from remotePatterns entirely.
Which Solution I Chose
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.
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.
Doing the Migration
Step 1: Download Everything
I wrote a small Node script that walked every post, matched the same three-host regex, and pulled down each unique URL:
const HOST_RE =
/src="(https:\/\/(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"/g;
// ...for each match, download to downloaded-images/<slug>/<filename>
// and record oldUrl -> "images/writing/<slug>/<filename>" in url-map.json
Files were organized by post slug specifically to avoid collisions, since a lot of these screenshots share generic names like 2022-01-20_18-50.png across completely different tutorials. Lastly, it was a total of 691 downloads with 0 failures, about 105 MB in total.
Step 2: Upload to R2
I restructured the download folder to mirror the target key prefix (images/writing/<slug>/...) and uploaded it to the bucket using CS Browser. R2 exposes an S3-compatible API and a GUI client handles hundreds of nested files more reliably than dragging a folder through the dashboard.
Step 3: Verify Before Touching content
Before rewriting anything, I spot-checked a couple of the newly uploaded URLs:
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
Step 4: Rewrite the MDX
A second script read url-map.json and replaced every matching src="..." in content/posts/*.mdx with the equivalent cdn.fahimbinamin.com URL:
content = content.replace(HOST_RE, (match, url) => {
const entry = urlMap[url];
if (!entry) return match; // left untouched, logged for review
return `src="${CDN_BASE}/${entry.cdnPath}"`;
});
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.
Step 5: Clean Up the Config
With no more <Image> references to the external hosts, I removed all three from images.remotePatterns in next.config.ts:
// 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: "/**" },
I ran a full production build afterward. All 43+ blog posts pre-rendered clean, lint passed, and nothing was broken.
Why This Is the Better Fix Long-Term
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.
unoptimized 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.
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.
What I Will Do Differently Next Time
The two remotePatterns 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.
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.
I've written that rule down now, so future backfills download and re-host before a single <img> tag ever points off-domain.
Conclusion
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.
To get more content like this, you can follow me on LinkedIn and X. You can also check my website and follow me on GitHub if you're into open source and development. If you like to watch programming and technology-related videos, then you can subscribe to my YouTube channel as well.