OG Image Generation

Generate OG Images from HTML Templates

Turn any HTML template into a 1200×630 Open Graph image using real headless Chromium. Full CSS support. Free, self-hosted, no API key, no design tools required.

View on GitHubAPI Documentation →

What is OG image generation?

OG image generation is the process of automatically creating Open Graph preview images — the 1200×630 cards that appear when a URL is shared on Twitter/X, LinkedIn, Slack, iMessage, and other platforms. Pages with custom OG images receive 2–3× more clicks from social shares than pages displaying a generic screenshot or no image.

The standard approach: write an HTML template with your brand, title, author, and date; render it at 1200×630 using headless Chromium; save the PNG alongside your page. No design tools needed — if you can write HTML and CSS, you can generate OG images.

Why HTML templates beat design tools

Hand-designing a unique OG image for every blog post doesn't scale. HTML templates let you:

The Openkova approach

Openkova accepts a raw HTML string and returns a PNG — that's the entire workflow. Because it uses real headless Chromium (not a custom renderer), any CSS that works in Chrome works in your OG images.

curl -X POST https://your-openkova-instance/api/screenshot \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<html><body style=\"margin:0;background:#0f0f0f;display:flex;align-items:center;justify-content:center;height:630px;font-family:sans-serif\"><div style=\"color:#e8e8e8;font-size:48px;font-weight:700;padding:60px\">My Blog Post Title</div></body></html>",
    "width": 1200,
    "height": 630,
    "format": "png"
  }' \
  --output og-image.png

For a reusable template, keep the HTML separate and interpolate dynamic values:

// generate-og.ts
import fs from 'fs';

async function generateOGImage(title: string, author: string, date: string) {
  const template = fs.readFileSync('./og-template.html', 'utf-8')
    .replace('{{title}}', title)
    .replace('{{author}}', author)
    .replace('{{date}}', date);

  const res = await fetch('https://your-openkova-instance/api/screenshot', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ html: template, width: 1200, height: 630, format: 'png' }),
  });

  return res.arrayBuffer();
}

Comparison: OG image generation approaches

ApproachCSS supportCostSelf-hostedBuild-timeOn-demand
OpenkovaFull (Chromium)Free
@vercel/og (Satori)Limited subsetFree (Vercel only)
CloudinaryLimited overlays$0.05–$0.12/image
Custom PuppeteerFull (Chromium)Server cost
html-to-image (browser)Partial (Canvas)FreeClient only

CSS features that work in Openkova OG images

Because Openkova uses real Chromium, all CSS that Chrome supports works in your OG images. No limitations, no undocumented quirks:

@vercel/og limitations

Satori (the renderer behind @vercel/og) reimplements a CSS subset in JavaScript — it does not use a real browser. Known limitations as of 2026:

Build-time OG image generation

For static sites (Next.js static export, Astro, Eleventy), generate all OG images at build time. This is the fastest approach — images are static files, no server required at runtime.

// scripts/generate-og-images.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const POSTS_DIR = './content/blog';
const OUTPUT_DIR = './public/og';

const posts = fs.readdirSync(POSTS_DIR)
  .filter(f => f.endsWith('.mdx'));

for (const file of posts) {
  const { data } = matter(fs.readFileSync(path.join(POSTS_DIR, file), 'utf-8'));
  const slug = file.replace('.mdx', '');

  const html = `
    <html>
    <head>
      <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
          width: 1200px; height: 630px;
          background: #0f0f0f;
          font-family: 'Inter', sans-serif;
          display: flex; align-items: center;
          padding: 80px;
        }
        .title { color: #e8e8e8; font-size: 52px; font-weight: 700; line-height: 1.2; }
        .tag { color: #7c6af7; font-size: 18px; margin-bottom: 24px; text-transform: uppercase; letter-spacing: 0.1em; }
      </style>
    </head>
    <body>
      <div>
        <div class="tag">${data.tag ?? 'Blog'}</div>
        <div class="title">${data.title}</div>
      </div>
    </body>
    </html>
  `;

  const res = await fetch('https://your-openkova-instance/api/screenshot', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ html, width: 1200, height: 630, format: 'png' }),
  });

  const buffer = await res.arrayBuffer();
  fs.writeFileSync(path.join(OUTPUT_DIR, `${slug}.png`), Buffer.from(buffer));
  console.log(`Generated: ${slug}.png`);
}

On-demand OG image generation (Next.js route handler)

For dynamic content, generate OG images on-demand in a Next.js route handler. Cache the response to avoid regenerating on every request.

// app/api/og/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs';

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const title = searchParams.get('title') ?? 'Untitled';
  const tag = searchParams.get('tag') ?? 'Blog';

  const html = `
    <html><head>
    <style>
      * { box-sizing: border-box; margin: 0; padding: 0; }
      body { width: 1200px; height: 630px; background: #0f0f0f;
        font-family: sans-serif; display: flex; align-items: center; padding: 80px; }
      .tag { color: #7c6af7; font-size: 18px; margin-bottom: 20px;
        text-transform: uppercase; letter-spacing: 0.1em; display: block; }
      .title { color: #e8e8e8; font-size: 52px; font-weight: 700; line-height: 1.2; }
    </style>
    </head><body><div>
      <span class="tag">${tag}</span>
      <div class="title">${title}</div>
    </div></body></html>
  `;

  const res = await fetch(process.env.OPENKOVA_URL + '/api/screenshot', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ html, width: 1200, height: 630, format: 'png' }),
  });

  const image = await res.arrayBuffer();

  return new NextResponse(image, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  });
}

Reference the route in your page metadata:

export const metadata: Metadata = {
  openGraph: {
    images: [{ url: `/api/og?title=${encodeURIComponent(title)}&tag=${tag}`, width: 1200, height: 630 }],
  },
};

OG image generation in CI/CD

Generate OG images as part of your build pipeline using @openkova/cli — no running server or Docker container required. The CLI uses your local Chromium installation directly.

# .github/workflows/og-images.yml
name: Generate OG Images
on: [push]

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

      - name: Install @openkova/cli
        run: npm install -g @openkova/cli

      - name: Generate OG images
        run: |
          for file in public/og-templates/*.html; do
            slug=$(basename "$file" .html)
            kova screenshot "$file" --output "public/og/$slug.png" --width 1200 --height 630
          done

      - name: Commit OG images
        run: |
          git add public/og/
          git diff --staged --quiet || git commit -m "chore: regenerate OG images"

Deploy Openkova for OG image generation

Openkova ships as a Docker image with bundled Chromium. One command and it's running:

docker run -p 3000:3000 ghcr.io/scnix-git/openkova:latest

For production, use Docker Compose with a persistent volume:

# docker-compose.yml
services:
  openkova:
    image: ghcr.io/scnix-git/openkova:latest
    ports:
      - "3000:3000"
    restart: unless-stopped
    volumes:
      - ./data:/app/data

For cloud deployments (Railway, Fly.io, Render), see the full documentation. The Docker deployment guide covers production configuration and storage.

Frequently asked questions

What is OG image generation?

OG image generation is the automated creation of Open Graph preview images — the 1200×630 cards shown when a URL is shared on social platforms. Pages with OG images get 2–3× more clicks on social shares than pages without them.

How do I generate OG images from HTML?

Pass your HTML template as a string to Openkova with width: 1200, height: 630, and format: "png". It renders the HTML using headless Chromium and returns the PNG. You can do this at build time, on-demand in a server route, or in CI/CD using @openkova/cli.

What is the difference between @vercel/og and Openkova?

@vercel/og uses Satori, a JavaScript JSX-to-SVG renderer with limited CSS support (no Grid, no pseudo-elements, no calc() in some cases). Openkova uses real headless Chromium — any CSS that works in Chrome works in your OG images. Trade-off: @vercel/og is zero-setup on Vercel; Openkova requires self-hosting but has no CSS constraints.

Can I use custom fonts in OG images?

Yes. In your HTML template, include a @font-facedeclaration pointing to a font URL or embed the font as a base64 data URI. Openkova's Chromium instance will load and render the font exactly as Chrome does — no manual font buffer loading required.

Can I generate OG images in GitHub Actions?

Yes. Install @openkova/cli in your workflow and run kova screenshot template.html --width 1200 --height 630 --output og.png. No server required — the CLI uses the local Chromium that GitHub Actions includes.

How much does OG image generation cost with Openkova?

Openkova is free — MIT-licensed, self-hosted, no per-image charges. A $20/mo VPS handles thousands of OG images per day. Cloudinary charges $0.05–$0.12 per transformation on paid plans; at 10,000 images that's $500–$1,200/month versus $20/month for a self-hosted Openkova instance.