Back to home
astro cloudflare og-images tutorial puppeteer
7 min read

How I Generated Dynamic OG Images with Astro and Cloudflare

After failing with @vercel/og and WASM compatibility issues on Cloudflare Workers, I found a solution using browser rendering and cron jobs. Here's the complete story.

Stan Leon
Stan Leon
Developer & Designer

How I Generated Dynamic OG Images with Astro and Cloudflare (After Failing Miserably)

You know that feeling when something that should be simple… just isn’t? That was me trying to generate Open Graph images for eventcompletion.com.

Each event on the platform needs its own unique OG image — with the event title, a progress bar showing how far along the event is (0-100%), a status indicator (upcoming, ongoing, or completed), and support for 25+ languages. Sounds straightforward, right?

Spoiler: It wasn’t.

In this post, I’ll share my journey of trying every “normal” way to generate OG images on Cloudflare Workers, running into WASM and Edge compatibility issues, and finally landing on a solution using Cloudflare’s browser rendering with an hourly cron job.

The Problem: Dynamic Images for Every Event

For eventcompletion.com, I needed OG images that:

  • Show event progress — A visual progress bar from 0-100% based on current date
  • Display the event status — Upcoming, ongoing, or completed
  • Support 25+ languages — Status labels must be localized
  • Look great on social media — You know, the whole point of OG images

The standard 1200x630 pixel OG size, but with dynamic content that changes as events progress.

The Failed Attempts

Attempt 1: @vercel/og (The “Normal” Way)

First, I tried using @vercel/og with Astro. It’s the go-to solution for OG image generation, right? Well… not on Cloudflare Workers.

The issue? @vercel/og uses Resvg and Satori, which rely on WASM. And Cloudflare Workers… let’s just say they don’t play nice with all WASM modules, especially when you’re dealing with the Edge runtime and Node.js compatibility layers.

Attempt 2: Node.js Compatibility Flags

I tried adding nodejs_compat to my wrangler.toml:

compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

This helped with some Node.js APIs, but the WASM issues persisted. The browser APIs that some image generation libraries depend on just aren’t available in the Workers runtime.

Attempt 3: More Packages, More Problems

I tested multiple packages — different Satori implementations, alternative canvas libraries, you name it. Each one hit the same wall: WASM compilation errors or missing browser APIs when running on Cloudflare’s Edge runtime.

The core issue: Cloudflare Workers run in a restricted environment that doesn’t support everything a typical Node.js server can do. Image generation libraries often depend on native modules or browser APIs that aren’t available.

The Solution: Cloudflare Browser Rendering + Cron Job

After banging my head against the wall, I discovered Cloudflare’s Browser Rendering service. It’s essentially Puppeteer running on Cloudflare’s infrastructure — and it actually works!

How It Works

Instead of trying to render images directly with WASM-heavy libraries, I:

  1. Use Puppeteer to render HTML in a headless browser
  2. Take a screenshot of the rendered page
  3. Store images in R2 (Cloudflare’s S3-compatible storage)
  4. Serve from cache with on-demand generation as fallback

The Architecture

// src/pages/api/og/[slug].ts
export async function GET({ params }) {
  const { slug } = params;

  // Check R2 cache first
  const cached = await env.IMAGES.get(`og-images/${slug}.png`);
  if (cached) {
    return new Response(cached.body, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=86400' // 24 hours
      }
    });
  }

  // Generate on-demand if not cached
  const image = await generateOGImage(event, env);
  await env.IMAGES.put(`og-images/${slug}.png`, image);

  return new Response(image, {
    headers: { 'Content-Type': 'image/png' }
  });
}

The Cron Job

Instead of generating images on every request (which would be slow), I use an hourly cron job to pre-generate images for active events:

# wrangler.toml
[triggers]
crons = ["0 * * * *"]  # Every hour at minute 0

The cron job is smart about what it generates:

// src/pages/api/og/generate-all.ts
const now = Date.now();
const startDate = new Date(event.start_date).getTime();
const endDate = new Date(event.end_date).getTime();

// Skip upcoming events (0% progress - nothing to show yet)
if (now < startDate) return;

// Skip events completed more than 3 hours ago (no longer relevant)
if (now > endDate + 3 * 60 * 60 * 1000) return;

// Only generate images for active/current events
generateAndStore(event);

This means the cron job only processes events that are actually happening or recently completed — saving processing time and storage.

The Image Generation

Here’s the core logic for calculating progress and status:

// src/utils/og-generator.ts
const now = Date.now();
const startDate = new Date(event.start_date).getTime();
const endDate = new Date(event.end_date).getTime();

let progress: number;
let status: 'upcoming' | 'ongoing' | 'completed';

if (now < startDate) {
  progress = 0;
  status = 'upcoming';
} else if (now > endDate) {
  progress = 100;
  status = 'completed';
} else {
  // Calculate progress percentage
  progress = Math.min(
    Math.max(((now - startDate) / (endDate - startDate)) * 100, 0),
    100
  );
  status = 'ongoing';
}

Then Puppeteer renders the HTML template with these values:

const browser = await env.BROWSER.connect();
const page = await browser.newPage();

await page.setContent(template({ event, progress, status }));
const screenshot = await page.screenshot({
  type: 'png',
  clip: { x: 0, y: 0, width: 1200, height: 630 }
});

await browser.disconnect();

The HTML Template

The template is just regular HTML/CSS, which is great because:

  • No JSX or React dependencies
  • Easy to style with Tailwind CSS
  • Full browser API support (since it’s running in a real browser)
<div class="og-image" style="width: 1200px; height: 630px; ...">
  <div class="header">
    <h1>{{ event.name }}</h1>
    <span class="status">{{ statusLabel }}</span>
  </div>

  <div class="progress-container">
    <div class="progress-bar" style="width: {{ progress }}%"></div>
  </div>

  <div class="footer">
    <img src="{{ event.banner }}" alt="Event banner" />
  </div>
</div>

Why This Works So Well

1. Reliability

By using Cloudflare’s browser rendering, I avoid all the WASM and Edge compatibility headaches. Puppeteer on Cloudflare is a first-class service — it just works.

2. Performance

  • R2 caching means most requests are served instantly
  • Cron job pre-generates images, so users rarely wait for generation
  • On-demand fallback ensures images are always available, even if the cron misses one

3. Scalability

Cloudflare’s infrastructure handles the traffic spikes. When an event goes viral and thousands of people share the link, the CDN serves the cached images without breaking a sweat.

4. Flexibility

Because I’m rendering real HTML, I can:

  • Use any CSS feature (gradients, flexbox, grid)
  • Include images from R2 or external sources
  • Support any language via simple string substitution
  • Change the design without rebuilding or redeploying

The Setup

If you want to implement something similar, here’s what you need:

npm install @cloudflare/puppeteer
# wrangler.toml
[browser]
binding = "BROWSER"

[[r2_buckets]]
binding = "IMAGES"
bucket_name = "your-bucket-name"

[triggers]
crons = ["0 * * * *"]  # Hourly
// Environment variables in Cloudflare dashboard
// CRON_SECRET — to secure your cron endpoint

What I Learned

  1. “Standard” solutions don’t always work on Edge — What works on Vercel or a traditional server might not work on Cloudflare Workers, and that’s okay.

  2. Use the platform’s strengths — Cloudflare Browser Rendering is designed for exactly this use case. Sometimes the platform-specific solution is better than the “universal” one.

  3. Cron + Cache = Performance — Generating images ahead of time and caching them is way more efficient than generating on-demand.

  4. Have a fallback — On-demand generation as a fallback means your site never breaks, even if the cron job fails.

Final Thoughts

Generating dynamic OG images on Cloudflare Workers was harder than I expected, but the solution I ended up with is more robust and scalable than the “easy” paths I tried initially.

If you’re struggling with OG image generation on Cloudflare (or any Edge platform), give browser rendering a shot. It might feel like “overkill” to spin up a browser just to take a screenshot, but for dynamic content, it’s hard to beat.

And hey — at least you won’t have to debug WASM compilation errors at 2 AM.


Want to see the final result? Check out eventcompletion.com and share an event on social media to see the OG images in action.

Questions? Feel free to reach out — let’s talk about OG images, Cloudflare Workers, or your own Edge runtime struggles.

Share this article

If you found this helpful, share it with others

Link copied!