Get started with 33% off your first certification using code: 33OFFNEW

How to generate dynamic social media images for every platform from one template

9 min read
Published on 21st May 2026

Every platform wants its own size. Open Graph is 1200 by 630. Twitter and X prefer 1200 by 675. LinkedIn quietly recommends 1200 by 627. Pinterest is a 1000 by 1500 portrait. Instagram is a 1080 by 1080 square in feed and 1080 by 1920 vertical in stories. YouTube wants 1280 by 720. Facebook ends up roughly at 1200 by 630 again.

You can paper over the gaps with a single 1200 by 630 image and let each platform crop. The result is fine, but it does not stand out and your title gets clipped half the time. The version that actually drives clicks is per-platform images, sized correctly, with the same brand language across all of them.

This article walks through how to generate dynamic social images for every platform from one HTML template, with working code in Laravel. The principle is the same whether you are on Node, Rails, or Django.

Why one image per platform fits poorly

The first instinct is always to ship one shared image. There are three problems with that.

The first is cropping. Each platform crops differently and shows a slightly different focal area. A title that sits dead centre at 1200 by 630 disappears under the LinkedIn comment overlay, gets cropped at the edges by Twitter on mobile, and looks lost in the middle of an Instagram square. The "safe area" of the image varies per platform and rarely lines up with the one you designed for.

The second is density. A title that reads cleanly at 1200 by 630 looks under-sized on a 1080 by 1920 Instagram story. You either commit to the lowest common denominator (small text everywhere) or accept that one of your variants will look weak.

The third is content density. A blog card might fit a title and author on a landscape image, but on a 1000 by 1500 Pinterest pin you have room for the title, author, key takeaways, and a clear call to action. Different aspect ratios let you say different things. Cropping a wide image down throws that opportunity away.

So you want one image per platform, sized correctly, generated dynamically from your post or product data. The question is how.

Three approaches

There are roughly three places you can do the rendering.

Approach 1: A Figma file per platform

A designer makes one template per platform and exports them as PNGs with editable text. Per post, somebody opens the right file, fills in the text, exports, and uploads. This works if you publish one post a week and have a designer on call. It does not scale to a content team shipping daily, nor to a product where every user-generated item needs its own share image.

Approach 2: Puppeteer on your own server

The developer answer: a Node or PHP service that renders an HTML template in a headless Chromium and screenshots it at the right viewport. In Laravel this usually means Spatie's Browsershot. You build N templates, you call windowSize(1200, 630) for one platform and windowSize(1080, 1920) for another, and you get the PNGs back.

It works, with the usual caveats. The Chromium binary is around 170 MB. It does not fit in a default AWS Lambda zip. You move to a layer or to @sparticuz/chromium. Memory consumption per render hovers around 250 MB. Fonts need to be pre-installed or pre-warmed, otherwise a cold start fires the screenshot before the webfont has loaded and you ship a fallback. Chrome version pinning becomes a recurring chore.

The cost is not just the initial setup. It is the steady drip of "the social image for one post looks wrong because Chromium updated overnight" and similar small failures that you cannot defend against without an operational rota.

Approach 3: A managed HTML to image API

The same HTML, posted to an API that runs the browser for you. You pay per render. The infrastructure work goes to zero. The trade-off is the dependency.

For the rest of this article we will use the html2img.com HTML endpoint, which takes an HTML string and a viewport size and returns a PNG URL. The pattern works the same way against any equivalent service.

The fan-out pattern

The key idea is that one HTML template can render at multiple sizes. You build the template responsively (in the actual CSS sense, with flexible layout and viewport units), then call the API once per platform with the platform's viewport.

Here is the shape of a Laravel job that takes a blog post and produces eight platform-sized images.

namespace App\Jobs;

use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class GenerateSocialImages implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    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 __construct(public Post $post) {}

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

            $response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
                ->timeout(30)
                ->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']],
            );
        }
    }

    private function orientationFor(array $size): string
    {
        if ($size['h'] > $size['w'] * 1.2) return 'portrait';
        if ($size['w'] > $size['h'] * 1.2) return 'landscape';
        return 'square';
    }
}

The template uses the orientation to switch between three layouts, not eight. A landscape layout for OG, Twitter, LinkedIn, Facebook, and YouTube. A portrait layout for Pinterest and Instagram story. A square layout for Instagram feed.

<!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: {{ $orientation === 'portrait' ? '24px' : '20px' }};
    color: #F59E0B;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 2px;
  }
  .title {
    font-size: {{ $orientation === 'portrait' ? '72px' : ($orientation === 'square' ? '64px' : '60px') }};
    font-weight: 900;
    line-height: 1.05;
    align-self: center;
    max-width: 920px;
  }
  .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>
  <div class="kicker">{{ $post->category }}</div>
  <div class="title">{{ $post->title }}</div>
  <div class="footer">
    <span><strong>{{ $post->author }}</strong> · {{ $post->published_at->format('jS F Y') }}</span>
    <span>yourdomain.example</span>
  </div>
</body>
</html>

A few practical details. The 100vw and 100vh make the body fill whatever viewport the renderer uses, so the same HTML adapts cleanly to landscape, portrait, and square calls. Title font size scales per orientation so the text always feels tight to the layout. The wait_for_selector: '.title' in the job makes sure the title is in the DOM before the screenshot fires, which matters when the webfont is still loading.

The output is eight PNG URLs per post, all rendered from one template. The engineering cost is one job, one template, one row in the database per platform.

When to use a pre-built template instead

If you do not want to maintain the HTML at all, html2img also exposes per-platform templates that take JSON and return the correctly-sized PNG. They are useful when your branding is settled and you do not need bespoke layout. The per-platform ones map directly onto the fan-out above:

Each one takes the same kind of input (title, subtitle, brand colour, optional image URL) and produces a tested PNG. The pattern in the Laravel job becomes shorter: instead of calling the HTML endpoint with a different viewport, you call each platform's template endpoint by name. You lose layout control, you gain not maintaining the template.

There is also a tweet mockup card template that renders a tweet-style card from data (avatar, body text, metrics) which is useful if you embed user-generated content into share images for your own content.

Caching, idempotency, and serving from your own CDN

Two things you want to get right early.

Cache by content hash, not by post ID. If a post title changes, the existing image is stale. Hashing the inputs that go into the image (title, author, category, version of the template) gives you a cache key that invalidates automatically when any of them change.

$cacheKey = md5(json_encode([
    'title' => $this->post->title,
    'author' => $this->post->author,
    'category' => $this->post->category,
    'platform' => $name,
    'template_version' => '2',
]));

Then check storage before calling the API. The vast majority of calls become cache hits and your bill stops being a linear function of post views.

Serve from your own CDN. The API gives you back a URL on its CDN. That works, but for compliance, predictability, and the awkward case where the upstream service has an outage, fetch the bytes once and persist them to S3 or your file storage. Serve from your own domain. Treat the upstream URL as a temporary handle, not the system of record.

Gotchas worth knowing about

A few things that will bite you the first time.

LinkedIn ignores og:image:width and og:image:height. It picks up the image dimensions itself and decides how to crop. The 1200 by 627 LinkedIn recommendation matters less than getting the focal content centre-weighted.

Twitter and X have two card types. summary_large_image is what you want for 1200 by 675. The plain summary card uses 144 by 144. Make sure your twitter:card meta tag is set to the right one.

Pinterest reads the actual og:image tag. It does not read a special pinterest:image. The cleanest pattern is to expose the Pinterest variant via a query parameter on your image URL (?platform=pinterest) and have the Pinterest pin button on your page link to it directly.

The Instagram story 1080 by 1920 is a different design problem. A landscape-feeling card stretched portrait reads badly. The orientation switch above gives you a portrait-shaped layout where the title sits in the top third and the footer at the bottom. Use it.

Avoid external image references inside the HTML if you can. Logos and avatars hosted on third-party CDNs slow down the render and occasionally fail to load. Embed your brand assets as inline SVG or base64 data URIs. The HTML can be longer; the render gets faster and more reliable.

Picking an approach

The honest decision tree, if you were sketching it on a whiteboard:

  • One post a week, branding rarely changes, designer on call. Stay manual. Do not over-engineer.
  • Daily content cadence, engineering capacity to spare, infrastructure work is fine. Build approach 2 with Browsershot or Puppeteer. Pay the Chromium tax, get full control, own the operational cost.
  • Any meaningful volume, social images are a feature, not the product. Approach 3, with a fan-out job per post. Either the HTML endpoint for full design control, or the per-platform templates for zero design work.

Most teams that publish daily land somewhere on approach 3 with a small custom HTML template, because it gives you the design flexibility without the operational tax. The work of building the eight-template fan-out is roughly an afternoon. The work of running Chromium reliably is roughly forever.

If you want to see this pattern applied to just Open Graph as a starting point, the dynamic Open Graph images in Laravel article on this site is a focused walk-through that uses the same code shape.