Skip to content

Implementing a dynamic OGP image generator for our blog — PHP GD, per-post 1200×630 cards

One day on our English X account, I posted a link to a fresh blog article and froze when the OGP card rendered: the image was the LP sales banner — “Stop babysitting updates. Start scaling maintenance revenue.” — not anything related to the post itself.

Going back through the last seven announcement posts, every single one was showing the same LP sales banner. Articles written as technical engineering deep-dives had been quietly flowing through the X timeline for months looking like sales-promo posts.

This article walks through how we found the structural cause and replaced it with a dynamic OGP image generation engine — using our own blog as the example.

The structural cause — “no featured image → LP default”

Digging in, the theme’s OGP logic was a classic fallback: “if there’s a featured image use it, otherwise the LP default.” The problem was that none of our 21 articles × 2 languages = 42 posts had a featured image. Every post was returning the same LP sales banner as og:image.

Note: OGP (Open Graph Protocol) is the metadata SNS clients use to render link previews. X, Facebook, LinkedIn, Slack and others all read og:image and friends.

Two options — manual artwork vs dynamic generation

Two repair paths were on the table.

Option A. Manually set a featured image on all 21 posts. Honest answer, but (1) producing 42 images once is heavy, (2) every new article afterward inherits an ongoing artwork cost, and (3) the “Japanese style” stock photos (PCs, keyboards, abstract code) read as amateur to international tech audiences, which works against us

Option B. Build a dynamic OGP image generator. The international tech-blog standard pattern. Vercel, PlanetScale, dev.to and friends all generate black-background + headline-composed OGP cards on demand — render once, cache, and every new post automatically inherits a per-article OGP

We picked Option B:

  • One engine, then 21 posts (and every future post) auto-resolve correctly
  • “Black + title” composition is the visual register international tech readers expect
  • “Find a stock image for every article” stops being a step

The core — ogp-generator.php (PHP GD, ~375 lines)

PHP GD (the image-manipulation library shipped with PHP) renders the 1200×630 PNG on demand. Everything sits in one standalone PHP file, ogp-generator.php.

// ogp-generator.php (sketch)
$post_id = (int)($_GET['post_id'] ?? 0);
$cache_path = WP_CONTENT_DIR . "/uploads/ogp/post-{$post_id}.png";

// 2nd hit onward: serve from disk cache
if (file_exists($cache_path)) {
    header('Content-Type: image/png');
    readfile($cache_path);
    exit;
}

// First hit: pull title from WP DB
$post = get_post($post_id);
$title = $post->post_title;

// Render 1200×630 PNG via GD
$im = imagecreatetruecolor(1200, 630);

// Background #0d1117 (LP main dark)
$bg = imagecolorallocate($im, 0x0d, 0x11, 0x17);
imagefilledrectangle($im, 0, 0, 1200, 630, $bg);

// Top blue 4px / bottom green 4px (visual continuity with LP)
$blue  = imagecolorallocate($im, 0x2f, 0x81, 0xf7);
$green = imagecolorallocate($im, 0x3f, 0xb9, 0x50);
imagefilledrectangle($im, 0, 0, 1200, 4, $blue);
imagefilledrectangle($im, 0, 626, 1200, 630, $green);

// Title text (font picked by language)
$font  = has_japanese($title) ? NOTO_BOLD : INTER_BOLD;
$lines = wrap_text($title, $font, 56, 900);
$y = 220;
$white = imagecolorallocate($im, 0xff, 0xff, 0xff);
foreach ($lines as $line) {
    imagettftext($im, 56, 0, 100, $y, $white, $font, $line);
    $y += 88;
}

// Cache to disk and serve
imagepng($im, $cache_path);
header('Content-Type: image/png');
readfile($cache_path);

Nothing fancy, but the key components are all there. The first request renders; every request after that streams the cached PNG from disk — the GD cost is paid exactly once per post.

Japanese titles need a second font — Inter + Noto Sans JP

The first thing that broke was the fact that Inter has no Japanese glyphs at all. It’s a Latin-script-only font; the JIS X 0208 range doesn’t exist. Passing a Japanese title through imagettftext() with Inter produces tofu (□□□) or nothing.

The fix: detect whether the title contains Japanese and swap fonts.

function has_japanese(string $text): bool {
    return preg_match('/[ぁ-んァ-ヶ一-龯]/u', $text) === 1;
}

We bundled Noto Sans JP Bold (SIL Open Font License, 5.1 MB) and Inter Bold / Regular (same SIL OFL, ~325 KB each) into the theme assets and rsync them alongside the other theme files.

The functions.php patch — +28 / -2

The OGP logic redirects through the dynamic URL when there’s no featured image. The diff is small (+28 / -2), and the existing behavior (posts with a real featured image keep using it) is preserved.

// After: posts without a featured image return the dynamic URL
function wpmm_blog_og_image($post) {
    if (is_singular() && !has_post_thumbnail($post)) {
        $cache_bust = date('YmdHis');
        return home_url(
            "/wp-content/themes/wpmm-blog/ogp-generator.php" .
            "?post_id={$post->ID}&v={$cache_bust}"
        );
    }
    // Posts with a featured image still take precedence
    $thumb = get_the_post_thumbnail_url($post, 'full');
    return $thumb ?: LP_DEFAULT_OGP;
}

&v=YYYYMMDDHHMMSS is a cache-busting query string. When we update the theme and change the OGP design, this forces X and Facebook to re-crawl rather than serving their stale cached image.

Design decisions and the result

The canvas is 1200×630 (OGP standard, X large-image-card recommended). Background #0d1117, accent blue #2f81f7 / green #3fb950 are pulled from the LP brand palette; thin 4px blue (top) / green (bottom) bars give visual continuity with the LP. “WP MAINTENANCE MANAGER” top-left, headline center-left, supporting URL bottom-right. The whole composition is intentionally close to what Vercel’s og API and PlanetScale’s OGP cards look like — visual signals readers associate with proper engineering blogs.

After deploy, curl -I against all 42 og:image URLs confirmed every one was now serving the dynamic URL. On X TL the visual flipped from “LP sales banner” to “dark card with the post title.” Retroactively re-posting old announcements isn’t worth it since they’re past their relevance window — instead we’ll be tracking engagement deltas between “LP-image era” and “dynamic OGP era” as ongoing signal.

Closing — four principles for dynamic OGP

Four takeaways worth keeping from this round:

  1. WP theme OGP defaults can quietly produce “every post has the same image.” The classic if (no_thumb) return LP_DEFAULT fallback is well-meaning, but combined with a workflow that doesn’t set featured images it becomes one identical sales banner across every post. Audit og:image across all posts periodically — a curl -I sweep of 42 URLs takes seconds
  2. Manual artwork is both a workload tax and a tone hazard. Beyond the ongoing labor, “regional visual signals” matter — Japanese-style stock photography reads as amateur to international tech audiences. Dynamic generation sidesteps both
  3. PHP GD + WP DB + disk cache is a one-file, lightweight stack. Lighter than it sounds. First-render GD pass, then static file serving — per-request load is essentially zero after the first hit. You don’t need an edge runtime like Vercel’s to do this
  4. Japanese fonts trip you up at the first hour. Latin-only fonts like Inter / Roboto turn Japanese titles into tofu. Build language detection + Noto Sans JP into the path from day one

This article ended up being a meta piece about our own blog’s OGP, written for our blog — and naturally this very post now has a dynamic OGP card generated by the engine it describes. Worth checking your own blog’s og:image if you’re running one — similar regressions can sit under the surface for months without anyone noticing.