Features Pricing Docs Blog Playground Log In Sign Up
Back to Blog

How to Generate Open Graph Images with a Screenshot API

Learn how to automatically generate OG images from HTML templates using a screenshot API. Includes code examples for JavaScript, Laravel, and curl, plus tips on caching, storage, and debugging.

Why OG images matter

When someone drops a link to your site in Slack, Telegram, Twitter, or Facebook, the platform pulls a preview: title, description, and an image. That image is the Open Graph image, or OG image.

Without one, the platform either shows a gray rectangle or grabs a random image from your page. A company logo instead of a proper preview, a 32x32 favicon stretched to fill the entire block. It looks like a business card printed on a napkin.

Some numbers to put this in perspective: posts with a correct OG image get 40-50% more clicks than posts with a broken or missing preview. Twitter/X displays cards with images noticeably larger in the feed. LinkedIn shows image-based previews at roughly double the area compared to text-only ones.

The problem is that every page needs its own image. A blog with 50 articles needs 50 OG images. An online store with 500 products needs 500. Drawing each one in Figma by hand is possible, but life is short.

This is where automatic OG image generation with a screenshot API comes in.

How it works

The idea is simple. You create an HTML template that looks exactly like your desired OG image. You inject data into the template (article title, author name, date, category). You send that HTML to a screenshot API. The API launches a headless browser, renders your page, and returns a PNG or JPEG at 1200x630 pixels. Done, you have an OG image.

A screenshot API works like an invisible Chrome. It opens your HTML page, waits for everything to load (fonts, CSS, images), takes a screenshot, and returns the result. The difference from a regular screenshot is that you fully control the viewport: set width to 1200, height to 630, and get an image with the exact aspect ratio Open Graph requires.

Why not use Puppeteer directly? You can. But then you need a server with Chrome installed, enough RAM (Chrome eats at least 300 MB per instance), crash handling, and a request queue. At 100 requests a day that's manageable. At 1,000 it becomes a headache. A screenshot API handles the infrastructure for you.

Why not just take a screenshot manually?

Fair question. You wrote the HTML template, opened it in Chrome, hit Cmd+Shift+4 (or Print Screen), and saved the image. Why bother with an API?

For one or two pages, don't bother. A manual screenshot works fine. But here's when the manual approach falls apart:

Page count. You have a blog with 30 articles and publish 2 new ones per week. Six months later you're at 80 articles, each needing its own OG image. Opening the template, plugging in data, taking a screenshot, cropping to 1200x630, saving with the right filename — by week three you'll stop doing it.

Content updates. Changed an article title? The OG image no longer matches. You need to recreate it by hand. With an API, this happens automatically: update the article, a hook generates a new image.

Consistency. A manual screenshot means a slightly different viewport every time, different zoom, a forgotten scrollbar here, an uneven crop there. An API returns the same result every time: exactly 1200x630, no scrollbars, no extra pixels.

Pipeline integration. If OG generation is part of your publishing workflow (a Laravel Observer, a GitHub Action, a Zapier/n8n workflow), you can't plug a manual screenshot into that. An API is called with a single HTTP request from any code.

Bottom line: if you have a landing page with three pages, take screenshots by hand. If you have a blog, a store, or a SaaS with dozens or hundreds of pages, you need automation.

OG image requirements

Before writing any code, let's go over the technical requirements. Every platform behaves slightly differently, but there's a common standard.

Size: 1200x630 pixels. That's a 1.91:1 ratio, and it works on Facebook, Twitter/X, LinkedIn, Slack, Discord, Telegram, and WhatsApp. If you need one size for everything, this is it.

Format: PNG for images with text and sharp edges. JPEG for photos or images with gradients. WebP isn't supported everywhere yet (WhatsApp and some email clients may not display it).

File size: up to 5 MB for most platforms, but you should realistically stay within 200-500 KB. Facebook may fail to fetch a heavy image if the server response is too slow.

URL: absolute only. Not /images/og.png, but https://example.com/images/og.png. Platforms don't resolve relative paths.

Cache refresh: Facebook caches the OG image on first fetch. If you change the image, you need to manually clear the cache via Facebook Sharing Debugger. Twitter/X does the same through its Card Validator.

A note on X/Twitter specifically: it supports 1.91:1, but the "summary_large_image" card looks better at 2:1 (1200x600). If Twitter is your main channel, keep important content in the center "safe zone" of the image, because X may crop the top and bottom.

Meta tags: the minimum set

For platforms to pick up your OG image, add these meta tags to the page's <head>:

<!-- Open Graph (Facebook, LinkedIn, Telegram, WhatsApp) -->
<meta property="og:type" content="article" />
<meta property="og:url" content="https://example.com/blog/my-post" />
<meta property="og:title" content="Article title" />
<meta property="og:description" content="Short description, 1-2 sentences" />
<meta property="og:image" content="https://example.com/og/my-post.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

<!-- Twitter/X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Article title" />
<meta name="twitter:description" content="Short description" />
<meta name="twitter:image" content="https://example.com/og/my-post.png" />

The og:image:width and og:image:height tags aren't required, but they're strongly recommended. Without them, Facebook makes an extra request to determine image dimensions and sometimes shows a small preview instead of a large one.

Creating the HTML template

Here's where it gets interesting. Your HTML template is a mini web page sized at exactly 1200x630 pixels. No scroll, no responsiveness. A fixed size, like a poster.

Example template for a blog:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=1200">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            width: 1200px;
            height: 630px;
            overflow: hidden;
            font-family: 'Inter', system-ui, sans-serif;
            background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
            color: #fff;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            padding: 60px;
        }

        .category {
            font-size: 18px;
            font-weight: 600;
            text-transform: uppercase;
            letter-spacing: 2px;
            color: #38bdf8;
        }

        .title {
            font-size: 52px;
            font-weight: 800;
            line-height: 1.15;
            max-width: 900px;
            margin-top: 20px;
        }

        .bottom {
            display: flex;
            justify-content: space-between;
            align-items: flex-end;
        }

        .author {
            display: flex;
            align-items: center;
            gap: 16px;
        }

        .author-avatar {
            width: 48px;
            height: 48px;
            border-radius: 50%;
            background: #38bdf8;
        }

        .author-name { font-size: 18px; font-weight: 500; }
        .author-date { font-size: 14px; color: #94a3b8; }
        .logo { font-size: 20px; font-weight: 700; color: #38bdf8; }
    </style>
</head>
<body>
    <div>
        <div class="category">Tutorial</div>
        <h1 class="title">How to Generate OG Images with a Screenshot API</h1>
    </div>
    <div class="bottom">
        <div class="author">
            <div class="author-avatar"></div>
            <div>
                <div class="author-name">John Doe</div>
                <div class="author-date">March 18, 2026</div>
            </div>
        </div>
        <div class="logo">yourblog.com</div>
    </div>
</body>
</html>

And here's what it renders to:

A few things to note:

  1. Width and height are fixed. width: 1200px; height: 630px; overflow: hidden; guarantees the template won't stretch or produce a scrollbar.
  2. Fonts load separately. If you use Google Fonts, add the <link> in <head>. The screenshot API will wait for fonts to load before taking the screenshot. If the font fails to load, the browser falls back to a system font, and the OG image won't look how you intended.
  3. Title font size. 52px works well for titles up to about 60 characters. For longer titles, reduce to 44-48px or add JavaScript logic for automatic scaling.
  4. Safe zone. Don't place important text closer than 60px to the edges. Some platforms crop the edges slightly when displaying previews.

Making the template dynamic

A static HTML template is fine for understanding the concept. But we need to inject different data for each page.

The simplest approach is to use query parameters in the URL and generate the HTML with injected values on the server.

Example with Node.js and Express:

const express = require('express');
const app = express();

app.get('/og-template', (req, res) => {
    const { title, category, author, date } = req.query;

    const html = `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&display=swap" rel="stylesheet">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            width: 1200px; height: 630px; overflow: hidden;
            font-family: 'Inter', sans-serif;
            background: linear-gradient(135deg, #0f172a, #1e293b);
            color: #fff; display: flex; flex-direction: column;
            justify-content: space-between; padding: 60px;
        }
        .category { font-size: 18px; font-weight: 600;
            text-transform: uppercase; letter-spacing: 2px; color: #38bdf8; }
        .title { font-size: 52px; font-weight: 800;
            line-height: 1.15; max-width: 900px; margin-top: 20px; }
        .bottom { display: flex; justify-content: space-between;
            align-items: flex-end; }
        .author-name { font-size: 18px; font-weight: 500; }
        .author-date { font-size: 14px; color: #94a3b8; }
        .logo { font-size: 20px; font-weight: 700; color: #38bdf8; }
    </style>
</head>
<body>
    <div>
        <div class="category">${category || 'Article'}</div>
        <h1 class="title">${title || 'Untitled'}</h1>
    </div>
    <div class="bottom">
        <div>
            <div class="author-name">${author || ''}</div>
            <div class="author-date">${date || ''}</div>
        </div>
        <div class="logo">yourblog.com</div>
    </div>
</body>
</html>`;

    res.setHeader('Content-Type', 'text/html');
    res.send(html);
});

app.listen(3000);

Now a request to http://localhost:3000/og-template?title=My+Post&category=Tutorial&author=John&date=2026-03-18 returns an HTML page with the injected data. We'll feed this page to the screenshot API for rendering.

If you work with PHP/Laravel, the approach is the same: a Blade template, a route, and a controller that returns HTML.

Sending the template to a screenshot API

Now the main part. We have the template URL. We need to turn it into a PNG image.

Here's what that looks like with a screenshot API:

curl "https://api.screenshotrun.com/v1/screenshot?\
url=https://yoursite.com/og-template?title=My+Post&category=Tutorial\
&width=1200\
&height=630\
&format=png\
&api_key=YOUR_API_KEY"

The API takes the URL, opens it in headless Chrome, sets the viewport to 1200x630, takes a screenshot, and returns the PNG.

The same request in JavaScript:

const axios = require('axios');
const fs = require('fs');

async function generateOgImage(title, category, author, date) {
    const templateUrl = new URL('https://yoursite.com/og-template');
    templateUrl.searchParams.set('title', title);
    templateUrl.searchParams.set('category', category);
    templateUrl.searchParams.set('author', author);
    templateUrl.searchParams.set('date', date);

    const response = await axios.get('https://api.screenshotrun.com/v1/screenshot', {
        params: {
            url: templateUrl.toString(),
            width: 1200,
            height: 630,
            format: 'png',
            api_key: 'YOUR_API_KEY'
        },
        responseType: 'arraybuffer'
    });

    return Buffer.from(response.data);
}

const image = await generateOgImage(
    'How to Generate OG Images',
    'Tutorial',
    'John Doe',
    '2026-03-18'
);
fs.writeFileSync('og-image.png', image);

Alternative: sending HTML directly instead of a URL

Some screenshot APIs (including ScreenshotRun) support rendering HTML directly, without hosting the template at a separate URL.

const response = await axios.post('https://api.screenshotrun.com/v1/screenshot', {
    html: '<html><body style="width:1200px;height:630px;background:#0f172a;color:#fff;display:flex;align-items:center;justify-content:center;font-size:48px;font-family:sans-serif">Hello World</body></html>',
    width: 1200,
    height: 630,
    format: 'png',
    api_key: 'YOUR_API_KEY'
});

This is convenient for simple cases and for testing. No need to deploy a separate endpoint for the template.

The downside: if your HTML is heavy (with external fonts, SVG icons, background images), all those resources need absolute URLs. Relative paths won't work because the HTML has no base URL.

Practical example: OG images for a blog

Where to store the images

Yes, each page needs its own OG image. If all 30 articles point to the same og-image.png, every shared link in Telegram looks identical. Title and description will be different, but the image is the same. Not great.

Storing 30 (or 300) PNG files isn't as bad as it sounds. A single 1200x630 PNG weighs 200-400 KB. 30 articles is about 10 MB total. Less than one phone photo.

Three options for where to keep these files:

Local filesystem is the simplest. A public/og-images/ folder on your server, files named by article slug: how-to-generate-og-images.png, screenshot-api-tutorial.png. Nginx serves them as static files with zero load on the application. For a blog with up to 100-200 articles on a VPS, this is more than enough.

Object Storage (S3, Hetzner Object Storage) works if you don't want to fill up the VPS disk or if you have hundreds of images. The image URL points to the storage: https://your-bucket.fsn1.your-objectstorage.com/og/slug.png. Bonus: you can put Cloudflare in front of the bucket and get CDN for free.

On-the-fly generation through CDN means you don't store files at all. The OG image URL points to an endpoint like /og?title=..., which calls the screenshot API and returns the result. Cloudflare caches the response for a day. Downside: the first request is slow (1-3 seconds), and if the cache clears, you spend another API credit. It works, but the first two options are more reliable.

For a typical blog, the first option is best. No extra services, files sit alongside your other static assets.

The generation process

  1. Author publishes an article (or updates an existing one).
  2. A server hook grabs the title, category, author name, and date.
  3. Builds the template URL with those parameters.
  4. Calls the screenshot API.
  5. Saves the PNG to the chosen storage.
  6. Writes the image URL to the database for use in the page's meta tags.

In Laravel, the easiest way to try this is an Artisan command. No abstractions, run it in the terminal, get a file, confirm it works.

// app/Console/Commands/GenerateOgImage.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use App\Models\Article;

class GenerateOgImage extends Command
{
    protected $signature = 'og:generate {slug}';
    protected $description = 'Generate OG image for an article';

    public function handle()
    {
        $article = Article::where('slug', $this->argument('slug'))->firstOrFail();

        // 1. Build the template URL with article data
        $templateUrl = route('og-template', [
            'title' => $article->title,
            'category' => $article->category->name ?? 'Article',
            'author' => $article->author->name ?? '',
            'date' => $article->published_at?->format('M d, Y') ?? '',
        ]);

        $this->info("Template URL: {$templateUrl}");

        // 2. Send to the screenshot API
        $response = Http::timeout(15)->get('https://api.screenshotrun.com/v1/screenshot', [
            'url' => $templateUrl,
            'width' => 1200,
            'height' => 630,
            'format' => 'png',
            'api_key' => config('services.screenshotrun.key'),
        ]);

        if (!$response->successful()) {
            $this->error("API returned {$response->status()}");
            return 1;
        }

        // 3. Save PNG to public/og-images/
        $path = "og-images/{$article->slug}.png";
        Storage::disk('public')->put($path, $response->body());

        // 4. Write the image URL to the database
        $article->update(['og_image' => Storage::disk('public')->url($path)]);

        $this->info("Saved: {$path}");
        return 0;
    }
}

Run it:

php artisan og:generate how-to-generate-og-images

One command, and the file how-to-generate-og-images.png appears in public/og-images/, with the image URL saved in the database. You can open the file and confirm it looks right.

Want to generate images for all articles at once? Add a second command:

php artisan og:generate-all
// Inside handle():
Article::published()->each(function (Article $article) {
    $this->call('og:generate', ['slug' => $article->slug]);
});

Once you've confirmed generation works, you can automate it by attaching the same logic to a model Observer, so the OG image is created automatically whenever an article is published:

// app/Observers/ArticleObserver.php

class ArticleObserver
{
    public function created(Article $article)
    {
        // Dispatch to queue so saving isn't slowed down
        GenerateOgImageJob::dispatch($article);
    }

    public function updated(Article $article)
    {
        // Only regenerate if the title changed
        if ($article->wasChanged('title')) {
            GenerateOgImageJob::dispatch($article);
        }
    }
}

The Job contains the same code as the Artisan command, wrapped in ShouldQueue and executed in the background via a queue. The author clicks "Publish" in the admin panel, the article saves instantly, and the OG image gets generated a couple of seconds later in the background. No waiting.

Then in your Blade layout's <head>:

@if($article->og_image)
    <meta property="og:image" content="{{ $article->og_image }}" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
@endif

Beyond the blog: SaaS and other use cases

The same approach works for SaaS products: feature pages, user-generated content (dashboards, portfolios), documentation. All of it gets shared in Slack and Discord, and all of it needs a preview. Generate the image once when the content is created, and regenerate only on updates.

The basic template with text on a gradient is a starting point. You can go further: take a screenshot of the page itself through the API and use it as a background with a semi-transparent overlay, pull in SVG technology icons based on article tags, or set up multiple templates for different content types (blog, docs, changelog). It all renders through the same screenshot API — the only variable is the HTML template.

Caching and performance

Caching is a separate topic that can cost you money if you don't think about it upfront.

The main rule: generate the OG image when you publish content, not when Facebook or Telegram comes knocking for the image. Typical generation time is 1-3 seconds. That's fine for a background job, but if a social platform is waiting for a response and times out, it'll show a blank preview.

If you do generate OG images on the fly (via a URL like /og?title=...), put a CDN in front of that endpoint. Cloudflare or CloudFront with a Cache-Control: public, max-age=86400 header means the first request hits the API and every subsequent one is served from cache.

Some providers (including ScreenshotRun) also cache results on their end: a repeat request with the same URL returns the cached screenshot without re-rendering. This saves both time and credits. But don't rely on that alone — store your own copy.

Debugging and testing OG images

You've created the OG image and set up the meta tags. How do you verify it works? Use platform debuggers: Facebook Sharing Debugger, Twitter Card Validator, LinkedIn Post Inspector. Paste your URL — you'll see which tags get picked up and how the preview looks. You can clear the cache there too.

But the most reliable method is to send the link to yourself in Telegram, WhatsApp, and Slack. Sometimes the debugger shows everything correctly, but in practice a platform crops the image in unexpected ways.

Common problems:

  • Image doesn't update. Cache. Clear it via Facebook Debugger, or add ?v=2 to the image URL.
  • Small preview instead of large. Missing og:image:width and og:image:height tags, or the image is smaller than 600x315.
  • Image doesn't load. The URL isn't accessible externally (localhost, VPN, firewall).
  • Broken encoding. Non-ASCII characters in the title? Check URL encoding of the template parameters.

Comparison: screenshot API vs Puppeteer vs Satori vs Cloudflare Workers

There are several ways to generate OG images. Each has trade-offs.

Puppeteer/Playwright on your own server gives you full control. You can render anything: complex CSS, web fonts, SVG, canvas. In return, you get a server with 1-2 GB of RAM to monitor, because Chrome sometimes crashes for no obvious reason. Plus a request queue, plus updates.

Satori by Vercel takes a different approach. It doesn't launch a browser at all — it renders SVG from JSX markup. It's fast and deploys on Vercel as an edge function. The problem: Satori understands a limited subset of CSS. No grid, no pseudo-elements, flexbox with caveats. For a template that's "text on a gradient background," it's enough. For anything more complex, it's not.

Cloudflare Workers with @cloudflare/puppeteer is the serverless option. It scales well, but Cloudflare's Browser Rendering is still in beta. Limits on concurrent sessions and browser lifetime can catch you off guard in production.

Screenshot API — you send a URL or HTML, you get an image back. No infrastructure needed, full CSS and JavaScript support, pay per use. Dependency on an external service is the only real downside, but for OG images (generated in the background, not in real time) that's rarely a problem.

For smaller projects (up to 100 pages) a screenshot API is the best fit. You don't spend time on infrastructure and only pay for what you use. For larger projects with thousands of pages and frequent regeneration, running your own Playwright server may be more cost-effective.

Wrapping up

Automatic OG image generation through a screenshot API is something you can set up in a day and forget about for months. An HTML template gives you full design freedom: any CSS, any fonts, any layout. The screenshot API handles the rendering. All you do is inject data and save the result.

The minimum set of steps:

  1. Create an HTML template at 1200x630.
  2. Build an endpoint that injects data into the template.
  3. Call the screenshot API to get a PNG.
  4. Save the PNG and add the URL to your meta tags.
  5. Verify with Facebook Debugger and Twitter Card Validator.

If your blog or SaaS currently has no OG images, or uses the same static image for every page, automatic generation is one of those cases where minimal effort gives you a noticeable bump in link click-through rates.

More from the blog

View all posts

Best Screenshot API in 2026: Honest Comparison for Developers

Not all screenshot APIs are built the same. We compared the most popular options on the market — pricing, webhooks, rendering engines, free tiers, and real cost per screenshot — so you can pick the one that actually fits your project without overpaying for features you don't need.

Read more →