DEV Community

Cover image for One API, every social image - dynamic OG, Twitter, LinkedIn, Pinterest, YouTube
Accreditly
Accreditly

Posted on

One API, every social image - dynamic OG, Twitter, LinkedIn, Pinterest, YouTube

We were shipping one OG image per post. It was 1200 by 630. It was rendered server-side from a Blade template via Puppeteer. We were quite pleased with it.

Then someone in growth pointed out that the same image looked terrible on LinkedIn (cropped weirdly), unremarkable on Twitter (where 1200 by 675 actually shows in full), and tiny on Pinterest (where the feed is a vertical aspect ratio). The fix on paper was simple: make per-platform variants. The fix in practice was a fortnight of getting Puppeteer to behave at eight different viewport sizes.

This article is about the version that came after that. One HTML template, eight platform variants, the API runs the browser. No Chromium to maintain. Worth posting because the fan-out pattern is reusable and quite a lot smaller than what I was carrying before.

Why one shared image is not enough

The reflex is to ship a single 1200 by 630 and let each platform crop. That gets you 70% of the way there and looks fine. The reason teams that care about share traffic go further is that each platform has its own pathology:

  • OG / Facebook: 1200 by 630. Title in the centre, brand in a corner. Standard landscape.
  • Twitter / X: 1200 by 675 for summary_large_image. Slightly taller than OG. A 1200 by 630 image works but leaves a thin grey bar.
  • LinkedIn: 1200 by 627. Functionally identical to OG, but the comment overlay covers the bottom 80px on mobile. Anything at the bottom is invisible.
  • Pinterest: 1000 by 1500. Vertical. Completely different design problem. You have room for the title, key points, and a call to action.
  • Instagram square: 1080 by 1080. Title can be larger because the safe area is huge.
  • Instagram story: 1080 by 1920. Vertical. The top 200px is hidden by the username overlay, the bottom 250px by the reply bar. Real estate is the middle slab.
  • YouTube thumbnail: 1280 by 720. Bold, high contrast, big title. Different conventions to a blog OG. A blog post that gets shared into all of those wants eight images, sized correctly, generated from the same content. Building eight Puppeteer templates is a lot of work. Building one HTML template and rendering it at eight viewports is not.

The fan-out pattern

The principle: one HTML template, made flexible enough to render at three orientation modes (landscape, square, portrait), called by an API that handles the viewport per platform.

Here is the shape of a job that does this for a blog post. Laravel version:

public const PLATFORMS = [
    'og'        => ['w' => 1200, 'h' => 630],
    'twitter'   => ['w' => 1200, 'h' => 675],
    'linkedin'  => ['w' => 1200, 'h' => 627],
    'facebook'  => ['w' => 1200, 'h' => 630],
    'pinterest' => ['w' => 1000, 'h' => 1500],
    'square'    => ['w' => 1080, 'h' => 1080],
    'story'     => ['w' => 1080, 'h' => 1920],
    'youtube'   => ['w' => 1280, 'h' => 720],
];

public function handle(): void
{
    foreach (self::PLATFORMS as $name => $size) {
        $html = view('social.post', [
            'post' => $this->post,
            'orientation' => $this->orientationFor($size),
        ])->render();

        $response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
            ->post('https://app.html2img.com/api/html', [
                'html' => $html,
                'width' => $size['w'],
                'height' => $size['h'],
                'wait_for_selector' => '.title',
            ])
            ->throw()
            ->json();

        $this->post->socialImages()->updateOrCreate(
            ['platform' => $name],
            ['url' => $response['url']],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Node version, same shape:

const PLATFORMS = {
  og:        { w: 1200, h: 630 },
  twitter:   { w: 1200, h: 675 },
  linkedin:  { w: 1200, h: 627 },
  facebook:  { w: 1200, h: 630 },
  pinterest: { w: 1000, h: 1500 },
  square:    { w: 1080, h: 1080 },
  story:     { w: 1080, h: 1920 },
  youtube:   { w: 1280, h: 720 },
};

async function generateAll(post) {
  const results = {};
  for (const [name, size] of Object.entries(PLATFORMS)) {
    const html = renderTemplate(post, orientationFor(size));
    const res = await fetch('https://app.html2img.com/api/html', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': process.env.HTML2IMG_KEY,
      },
      body: JSON.stringify({
        html,
        width: size.w,
        height: size.h,
        wait_for_selector: '.title',
      }),
    });
    if (!res.ok) throw new Error(`render failed for ${name}: ${res.status}`);
    const { url } = await res.json();
    results[name] = url;
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Eight platforms, sequentially, in roughly 15 seconds total. If you want it faster, fire the requests in parallel with a concurrency limit of 3 to 5 (any higher and you start rate-limiting).

The template that handles three orientations

This is the bit that took the longest to get right. The same HTML has to read cleanly at 1200 by 630, 1080 by 1080, and 1080 by 1920. The trick is 100vw/100vh on the body, plus orientation-aware font sizing.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    width: 100vw; height: 100vh;
    background: linear-gradient(135deg, #0B1220 0%, #1E293B 100%);
    color: #fff;
    font-family: Inter, sans-serif;
    padding: 80px;
    display: grid;
    grid-template-rows: auto 1fr auto;
  }
  .kicker {
    font-size: 20px;
    color: #F59E0B;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 2px;
  }
  .title {
    font-size: 60px;
    font-weight: 900;
    line-height: 1.05;
    align-self: center;
    max-width: 920px;
  }
  body[data-orientation="square"] .title { font-size: 64px; }
  body[data-orientation="portrait"] .title { font-size: 72px; }
  body[data-orientation="portrait"] .kicker { font-size: 24px; }
  .footer {
    font-size: 22px;
    color: #94A3B8;
    display: flex;
    justify-content: space-between;
    align-items: end;
  }
  .footer strong { color: #fff; font-weight: 700; }
</style>
</head>
<body data-orientation="{{orientation}}">
  <div class="kicker">{{category}}</div>
  <div class="title">{{title}}</div>
  <div class="footer">
    <span><strong>{{author}}</strong> ยท {{published_at}}</span>
    <span>yourdomain.example</span>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The data-orientation attribute is the only thing that changes between calls. Three CSS selectors handle the three modes. The grid layout keeps the kicker at the top, the title vertically centred, and the footer at the bottom, no matter the viewport.

There is also a per-platform template approach where you don't write the HTML at all. The html2img per-platform templates cover every social size individually (OG, Twitter, LinkedIn, Pinterest, Instagram square, Instagram story, YouTube, Facebook). Same JSON payload shape, different endpoint per platform. Useful if your branding is settled and you don't want to maintain layout code.

Gotchas

The list that made me wish I'd known earlier.

wait_for_selector matters more than you'd think. Without it, the screenshot occasionally fires before the webfont has loaded and you get a fallback render. The selector should match an element that only renders once the font is in. Setting it to .title works because the title element gets its visible width from the loaded font.

Inline your brand assets. External image references slow the render and occasionally fail. Logos as inline SVG, avatars as base64 data URIs, hero images either inlined or pre-uploaded to your own fast CDN.

Cache by content hash. The naive cache key is the post ID, but if the title changes the existing image is stale. Hash the inputs:

$cacheKey = md5(json_encode([
    'title' => $post->title,
    'author' => $post->author,
    'platform' => $platform,
    'v' => '2',
]));
Enter fullscreen mode Exit fullscreen mode

Bumping the v field is your manual cache bust when the template design changes.

Persist the bytes, not the upstream URL. The API returns a hosted CDN URL. Convenient. But for compliance and the case where the upstream has an outage, fetch the bytes once and serve from your own storage. Treat the upstream URL as a one-shot handle, not the system of record.

LinkedIn overlays the bottom 80px on mobile. Don't put critical content there. The .footer in the template above is fine because it's brand metadata, not the message itself.

Don't try to use the same image for the Instagram story. A landscape card stretched portrait reads badly. The orientation switch in the template gives you a portrait layout with title at the top and brand at the bottom, which is the only way a 1080 by 1920 looks intentional.

Closing the loop in your meta tags

The render side is half the work. The other half is your HTML head, which has to point each platform at the right variant.

<head>
  <meta property="og:image" content="https://yourcdn.example/social/{{slug}}/og.png">
  <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="https://yourcdn.example/social/{{slug}}/twitter.png">

  <!-- LinkedIn reads og:image -->

  <!-- Pinterest reads og:image but you can also expose the pin variant
       as a direct URL for the Pin button -->
  <link rel="alternate" type="image/png"
        href="https://yourcdn.example/social/{{slug}}/pinterest.png"
        title="Pin this">
</head>
Enter fullscreen mode Exit fullscreen mode

Instagram, YouTube and the in-feed Twitter/X variants are not driven by meta tags. You upload them directly to those platforms (or hand them to the marketing team to upload).

The honest verdict

If your team publishes daily and shares to more than one platform, the fan-out approach earns its keep within a couple of weeks. Building it took roughly half a day from "I should do this" to "all eight variants ship per post". Maintaining it has been free. Owning Chromium for the same job was a fortnightly small-fire situation.

If you're publishing once a week, this is over-engineering. Stay with one 1200 by 630 and a Figma file.

The same pattern works for product cards on an e-commerce site, share images for user-generated content (think Strava-style activity cards), and the per-episode covers for a podcast network. The viewport list changes, the rest of the code stays the same.

Top comments (0)