I got an email from Vercel warning 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, gallery frames. Instead, almost every source image listed was hosted on freecodecamp.org.
That was confusing for a second, because I don't host my images on freeCodeCamp. Then I remembered: I do link to them.
Where These Images Actually Come From
I've cross-posted a lot of my writing to freeCodeCamp over the years. When I backfilled 39 of those 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, since that's 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 doesn't care whether 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: fetch the original, resize it for every breakpoint, re-encode to AVIF/WebP, cache the result. I'd added cdn-media-0.freecodecamp.org, www.freecodecamp.org, and cdn.hashnode.com to that allowlist months ago, in two separate commits, specifically so those backfilled images would render instead of breaking.
That worked. It also meant 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. Not my quota to spend 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.
Two Ways to Fix It
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: the browser fetches the image straight from freeCodeCamp, no resizing, no AVIF conversion, zero quota impact. Small diff, 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.
Option 1 was tempting for how little it 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 cost more up front but matched how every other image on the site already works, kept the optimization benefits, and removed the external dependency completely. I went with Option 2.
Doing the Migration
Step 1: download everything. A small Node script 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.jsonFiles 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. 691 downloads, 0 failures, ~105 MB 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 S3 Browser, since R2 exposes an S3-compatible API and a GUI client handled 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"
# 200Step 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}"`;
});33 files changed, 692 replacements (one image was referenced twice in the same post), 0 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: "/**" },Ran a full production build afterward. All 43+ blog posts prerendered clean, lint passed, nothing 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 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 that: I'd have kept depending on freeCodeCamp's URLs staying valid indefinitely, with no optimization to show for it either.
Self-hosting means every image on this site now goes through the same pipeline, same cache rules, same CDN, regardless of where the content originally lived. One less thing that can silently break because someone else changed a URL structure.
What I'd Do Differently Next Time
The two remotePatterns entries for freeCodeCamp and Hashnode were each added in their own commit, months apart, each time because a backfilled post's images were broken and adding the host was the fastest fix. Neither commit asked "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.

