Cloudflare R2 is object storage — like S3, but without egress fees to Cloudflare's network. Combined with a Worker that handles image serving, it becomes a decent image CDN without paying for Cloudflare's Image Resizing feature (which is a paid add-on).
Here's the complete pipeline: how images go from a PNG on my machine to a WebP served from the edge at the right size.
The Problem with Raw Images
A camera photo or a PNG screenshot can easily be 3-8MB. Serving that to every visitor is expensive in two ways: R2 bandwidth costs and user load time. A post hero image that takes 3 seconds to load on a 4G connection kills the reading experience before it starts.
The solution is to compress images before uploading them. Not at serve time — at publish time.
The Publish Script
Every post can have a hero image. I drop it in images/<slug>.(png|jpg|webp) and run npm run publish. The script finds the image, compresses it, uploads it to R2, and sets a flag in the post metadata.
The compression step uses sharp, a Node.js image processing library:
javascriptconst compressed = await sharp(imageBuffer)
.resize({ width: 1200, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
Three things happen:
- Resize to max 1200px wide — height scales proportionally, portrait images stay portrait
- Convert to WebP — WebP is roughly 30% smaller than JPEG at the same visual quality
- 80% quality — visually indistinguishable from 100% for photographs at this size
The result: a 5MB PNG becomes roughly 80-150KB WebP. That's a 30-60x reduction.
Uploading to R2
After compression, the script uploads to R2 using the Wrangler API:
bashwrangler r2 object put dwsa-posts/<slug> \
--file images/<slug>.webp \
--content-type image/webp
The R2 key matches the post slug exactly. The Worker retrieves it by slug when serving the image route.
After upload, the script sets image: true in the post's frontmatter and updates the KV index. The Worker reads this flag to decide whether to use the hero image or the default OG image for the post's og:image meta tag.
Serving the Image
The Worker handles /blog/:slug/image:
javascriptconst obj = await env.POSTS_BUCKET.get(slug);
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("Cache-Control", "public, s-maxage=31536000, max-age=86400");
return new Response(obj.body, { headers });
writeHttpMetadata() copies the stored Content-Type from R2 to the response. The cache headers tell Cloudflare's edge to cache this for one year — the image won't change once uploaded, and if it does, the new version gets a new URL (different slug).
The OG Image Connection
When a post has a hero image, the OG meta tags point to it:
html<meta property="og:image" content="https://dwsa-mm.com/blog/my-post/image" />
Twitter, WhatsApp, and LinkedIn all render this correctly because it's a proper WebP URL with the right Content-Type header. The Worker's dynamic SVG route (/blog/:slug/og) exists as a debug tool, but SVG doesn't work as an og:image — social platforms expect a raster format.
Static Assets: The Same Pattern
The same pipeline handles static site assets — favicon, logo, OG default image, hero video. They live in static/ and get uploaded to R2 by CI whenever the files change:
static/favicon.png → R2 key: "favicon" → served at /favicon.ico
static/logo.png → R2 key: "logo" → served at /logo
static/og-default.webp → R2 key: "og-default" → served at /og-image
The Worker checks these routes before the blog routes, so they're always fast regardless of KV state.
What You Don't Get Without Image Resizing
Cloudflare's paid Image Resizing feature serves different sizes based on width query params and automatically optimizes for the requesting browser. Without it, you serve one size for everyone — the 1200px WebP.
For a blog, one size is fine. The hero image is always displayed full-width on desktop, and mobile gets the same image scaled down by CSS. The 1200px WebP at 100KB loads fast on mobile too.
If you needed to serve retina images, thumbnails, or art-directed crops per breakpoint, you'd need Image Resizing or a different approach. For a personal blog, one compressed WebP per post is the right call.