DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to auto-generate OG images when you publish a new blog post

How to Auto-Generate OG Images When You Publish a New Blog Post

Every blog post needs an OG image — the preview card that shows up when someone shares your link on Twitter, LinkedIn, or Slack. Most teams either skip it (broken gray preview) or do it manually in Figma (doesn't scale).

The automated alternatives — Satori, @vercel/og, Puppeteer-based generators — all require either a Node.js Edge runtime or a headless browser. Here's the simpler path: one API call, image returned.

The pattern

publish post → webhook/build hook fires → fetch post data → render HTML card → PageBolt OG image → upload to CDN → update post metadata
Enter fullscreen mode Exit fullscreen mode

Ghost CMS webhook

// server.js — receives Ghost's post.published webhook
import express from "express";
import { uploadToCDN } from "./storage.js";

const app = express();
app.use(express.json());

app.post("/webhooks/ghost", async (req, res) => {
  const { post } = req.body;
  if (!post?.current) return res.json({ ok: true });

  const { title, excerpt, feature_image, slug } = post.current;

  const imageUrl = await generateAndUploadOgImage({ title, excerpt, slug });
  console.log(`OG image for "${title}": ${imageUrl}`);

  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Generate the OG image

async function generateAndUploadOgImage({ title, excerpt, slug }) {
  const html = renderOgCardHtml({ title, excerpt });

  const res = await fetch("https://pagebolt.dev/api/v1/og-image", {
    method: "POST",
    headers: {
      "x-api-key": process.env.PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      width: 1200,
      height: 630,
    }),
  });

  const imageBuffer = Buffer.from(await res.arrayBuffer());

  // Upload to S3, Cloudflare R2, or any CDN
  const cdnUrl = await uploadToCDN(imageBuffer, `og/${slug}.png`);
  return cdnUrl;
}
Enter fullscreen mode Exit fullscreen mode

OG card HTML template

function renderOgCardHtml({ title, excerpt }) {
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px;
      height: 630px;
      background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
      display: flex;
      align-items: center;
      padding: 80px;
      font-family: -apple-system, 'Segoe UI', sans-serif;
    }
    .card {
      max-width: 800px;
    }
    .tag {
      display: inline-block;
      background: #6366f1;
      color: white;
      font-size: 14px;
      font-weight: 600;
      padding: 6px 14px;
      border-radius: 20px;
      margin-bottom: 24px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }
    h1 {
      font-size: 56px;
      font-weight: 800;
      color: white;
      line-height: 1.1;
      margin-bottom: 20px;
    }
    p {
      font-size: 22px;
      color: rgba(255,255,255,0.65);
      line-height: 1.5;
    }
    .footer {
      position: absolute;
      bottom: 48px;
      right: 80px;
      color: rgba(255,255,255,0.4);
      font-size: 18px;
      font-weight: 600;
    }
  </style>
</head>
<body>
  <div class="card">
    <div class="tag">Blog</div>
    <h1>${title}</h1>
    ${excerpt ? `<p>${excerpt.slice(0, 120)}${excerpt.length > 120 ? "" : ""}</p>` : ""}
  </div>
  <div class="footer">yourblog.com</div>
</body>
</html>`;
}
Enter fullscreen mode Exit fullscreen mode

WordPress — on post publish (REST API)

// Webhook from WP Plugin or WP Webhooks plugin
app.post("/webhooks/wordpress", async (req, res) => {
  const { post_title, post_excerpt, post_name } = req.body;

  const imageUrl = await generateAndUploadOgImage({
    title: post_title,
    excerpt: post_excerpt,
    slug: post_name,
  });

  // Optionally update the post's custom OG field via WP REST API
  await fetch(`${process.env.WP_URL}/wp-json/wp/v2/posts/${req.body.ID}`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString("base64")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      meta: { _yoast_wpseo_opengraph_image: imageUrl },
    }),
  });

  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Next.js + GitHub Actions (static blog)

For static blogs (Next.js, Astro, Hugo) where there's no server to receive webhooks — run OG generation in CI after build:

# .github/workflows/og-images.yml
name: Generate OG images

on:
  push:
    branches: [main]
    paths:
      - "content/posts/**"

jobs:
  generate-og:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Generate OG images for new posts
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: node scripts/generate-og-images.js

      - name: Commit updated OG image references
        run: |
          git config user.email "ci@yourapp.com"
          git config user.name "CI Bot"
          git add public/og/
          git diff --staged --quiet || git commit -m "chore: generate OG images"
          git push
Enter fullscreen mode Exit fullscreen mode
// scripts/generate-og-images.js
import fs from "fs/promises";
import path from "path";
import matter from "gray-matter";

const postsDir = "./content/posts";
const ogDir = "./public/og";

const posts = await fs.readdir(postsDir);

for (const file of posts) {
  const slug = path.basename(file, ".md");
  const ogPath = path.join(ogDir, `${slug}.png`);

  // Skip if OG image already exists
  try {
    await fs.access(ogPath);
    continue;
  } catch {}

  const content = await fs.readFile(path.join(postsDir, file), "utf8");
  const { data } = matter(content);

  const html = renderOgCardHtml({ title: data.title, excerpt: data.description });

  const res = await fetch("https://pagebolt.dev/api/v1/og-image", {
    method: "POST",
    headers: {
      "x-api-key": process.env.PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, width: 1200, height: 630 }),
  });

  const buffer = Buffer.from(await res.arrayBuffer());
  await fs.writeFile(ogPath, buffer);
  console.log(`Generated OG image for: ${slug}`);
}
Enter fullscreen mode Exit fullscreen mode

Reference the image in your post frontmatter

---
title: "How to do X"
description: "A short description"
ogImage: "/og/how-to-do-x.png"
---
Enter fullscreen mode Exit fullscreen mode
<!-- In your layout -->
<meta property="og:image" content="https://yourblog.com/og/how-to-do-x.png" />
<meta name="twitter:image" content="https://yourblog.com/og/how-to-do-x.png" />
Enter fullscreen mode Exit fullscreen mode

Every post gets a branded, on-theme OG card automatically. No Figma, no manual exports, no Satori configuration.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)