Generate OG images for Astro sites
Open Graph (OG) images are the preview images that appear when you share a link on social media. Instead of manually creating these images for every blog post, you can use Cloudflare Browser Rendering to automatically generate branded social preview images from an Astro template.
In this tutorial, you will:
- Create an Astro page that renders your OG image design.
- Use Browser Rendering to screenshot that page as a PNG.
- Serve the generated images to social media crawlers.
- A Cloudflare account with Browser Rendering enabled
- An Astro site deployed on Cloudflare Workers
- Basic familiarity with Astro and Cloudflare Workers
Create an Astro route that renders your OG image design. This page serves as the source of truth for your image layout.
Create src/pages/social-card.astro:
---export const prerender = false;
const title = Astro.url.searchParams.get("title") || "Untitled";const image = Astro.url.searchParams.get("image");const author = Astro.url.searchParams.get("author");---
<html> <head> <meta charset="utf-8" /> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { width: 1200px; height: 630px; display: flex; flex-direction: column; justify-content: flex-end; padding: 60px; font-family: system-ui, sans-serif; background: linear-gradient(135deg, #f38020 0%, #f9a825 100%); color: white; } .title { font-size: 64px; font-weight: bold; line-height: 1.1; margin-bottom: 24px; } .author { font-size: 24px; opacity: 0.9; } .logo { position: absolute; top: 60px; left: 60px; height: 40px; } </style> </head> <body> <img class="logo" src="/your-logo.png" alt="Your logo" /> <h1 class="title">{title}</h1> {author && <p class="author">By {author}</p>} </body></html>Start your Astro development server to test the template:
npm run devTest locally by visiting http://localhost:4321/social-card?title=My%20Blog%20Post&author=Omar.
Before proceeding, deploy your site to ensure the /social-card route is live:
# For Cloudflare Workersnpx wrangler deployUpdate the BASE_URL in the script below to match your deployed site URL.
Generate all OG images during the Astro build process using the Cloudflare Browser Rendering REST API.
Create scripts/generate-social-cards.ts:
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";import { join } from "path";
// Configurationconst BASE_URL = "https://your-site.com"; // Your deployed site URLconst CF_API = "https://api.cloudflare.com/client/v4/accounts";const OUTPUT_DIR = "public/social-cards"; // Output directory for generated imagesconst POSTS_DIR = "src/data/posts"; // Directory containing your markdown posts (adjust to match your project)
interface Post { slug: string; title: string; author?: string;}
/** Extract a frontmatter field value from raw markdown content. */function getFrontmatterField(content: string, field: string): string | null { const match = content.match(new RegExp(`^${field}:\\s*"?([^"\\n]+)"?`, "m")); return match ? match[1].trim() : null;}
/** * Read all post files and return { slug, title, author }[]. * This function scans the POSTS_DIR for markdown files, extracts frontmatter * fields (slug, title, author), and returns an array of post objects. * Falls back to filename for slug and slug for title if frontmatter is missing. */function readPosts(): Post[] { if (!existsSync(POSTS_DIR)) return []; const files = readdirSync(POSTS_DIR).filter((f) => f.endsWith(".md")); return files.map((file) => { const raw = readFileSync(join(POSTS_DIR, file), "utf-8"); const slug = getFrontmatterField(raw, "slug") ?? file.replace(/\.md$/, ""); const title = getFrontmatterField(raw, "title") ?? slug; const author = getFrontmatterField(raw, "author") ?? undefined; return { slug, title, author }; });}
/** * Capture a screenshot using Cloudflare Browser Rendering REST API */async function captureScreenshot( accountId: string, apiToken: string, pageUrl: string): Promise<ArrayBuffer> { const endpoint = `${CF_API}/${accountId}/browser-rendering/screenshot`;
const res = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: pageUrl, viewport: { width: 1200, height: 630 }, // Standard OG image size gotoOptions: { waitUntil: "networkidle0" }, // Wait for page to fully load }), });
if (!res.ok) { const text = await res.text(); throw new Error(`Screenshot API returned ${res.status}: ${text}`); }
return res.arrayBuffer();}
async function main() { // Read credentials from environment variables const accountId = process.env.CF_ACCOUNT_ID; const apiToken = process.env.CF_API_TOKEN;
if (!accountId || !apiToken) { console.error("Error: CF_ACCOUNT_ID and CF_API_TOKEN required"); process.exit(1); }
// Check if --force flag is passed to regenerate all images const force = process.argv.includes("--force");
// Read posts from markdown files const posts = readPosts();
if (posts.length === 0) { console.log("No posts found. Check your POSTS_DIR path."); process.exit(0); }
console.log(`Found ${posts.length} posts to process\n`);
// Ensure output directory exists mkdirSync(OUTPUT_DIR, { recursive: true });
let generated = 0; let skipped = 0;
// Generate social card for each post for (let i = 0; i < posts.length; i++) { const post = posts[i]; const outPath = join(OUTPUT_DIR, `${post.slug}.png`); const label = `[${i + 1}/${posts.length}]`;
// Skip if file exists and --force flag not set if (!force && existsSync(outPath)) { console.log(`${label} ${post.slug}.png — skipped (exists)`); skipped++; continue; }
// Build URL with query parameters for the OG template const params = new URLSearchParams({ title: post.title, author: post.author || "", }); const url = `${BASE_URL}/social-card?${params}`;
try { // Capture screenshot and save to file const png = await captureScreenshot(accountId, apiToken, url); writeFileSync(outPath, Buffer.from(png)); console.log(`${label} ${post.slug}.png — done`); generated++; } catch (err) { console.error(`${label} ${post.slug}.png — failed:`, err); }
// Rate limiting: small delay between requests if (i < posts.length - 1) { await new Promise((resolve) => setTimeout(resolve, 200)); } }
console.log(`\nDone. Generated: ${generated}, Skipped: ${skipped}`);}
main();Set your Cloudflare credentials as environment variables:
export CF_ACCOUNT_ID=your_account_idexport CF_API_TOKEN=your_api_tokenRun the script to generate images:
# Generate new images onlybun scripts/generate-social-cards.ts
# Regenerate all imagesbun scripts/generate-social-cards.ts --forceOptionally, add to your build script in package.json:
{ "scripts": { "build": "bun scripts/generate-social-cards.ts && astro build" }}Update your blog post layout to reference the generated images:
---// src/layouts/BlogPost.astroconst { title, slug, author } = Astro.props;const ogImageUrl = `/social-cards/${slug}.png`;---
<html> <head> <meta property="og:title" content={title} /> <meta property="og:image" content={ogImageUrl} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:image" content={ogImageUrl} /> </head> <body> <slot /> </body></html>Before testing, make sure to deploy your site with the newly generated social card images:
# For Cloudflare Workersnpx wrangler deployUse these tools to verify your OG images render correctly:
---const title = Astro.url.searchParams.get("title") || "Untitled";const image = Astro.url.searchParams.get("image");---
<body style={image ? `background-image: url(${image})` : undefined}> <!-- content --></body><head> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@700&display=swap" rel="stylesheet" /> <style> body { font-family: "Inter", sans-serif; } </style></head>If your Astro site uses Tailwind, you can use it in your OG template:
---import "../styles/global.css";---
<body class="flex h-[630px] w-[1200px] flex-col justify-end bg-gradient-to-br from-orange-500 to-amber-500 p-16 text-white"> <h1 class="mb-6 text-6xl leading-tight font-bold">{title}</h1></body>Consider running generated images through Cloudflare Images or Image Resizing for additional optimization:
const optimizedUrl = `https://your-domain.com/cdn-cgi/image/width=1200,format=auto/social-cards/${slug}.png`;Your Astro site now automatically generates OG images using Browser Rendering. When you share a link on social media, crawlers will fetch the generated image from the static path.
From here, you can:
- Customize your template with custom fonts, Tailwind CSS, or background images.
- Add cache invalidation logic to regenerate images when post content changes.
- Use Cloudflare Images or Image Resizing for additional optimization.