OG Image Generation in 2026

Generate og:image thumbnails for social sharing — with Vercel OG, Playwright screenshot, or SnapAPI. For Next.js, Astro, SvelteKit, and static blogs.

OG ImagesNext.jsVercel OG PlaywrightSnapAPIApril 2026

Why OG Images Matter

When a URL is shared on Twitter/X, LinkedIn, Slack, or iMessage, the platform fetches the og:image meta tag and displays a visual card. Without a custom OG image, your links look generic. With one, click-through rates improve 2–5×.

The standard OG image size is 1200×630px. Twitter/X prefers 1200×628px for summary cards. Both work with 1200×630.

Three Approaches

ApproachBest forProsCons
Vercel OG (@vercel/og)Next.js / Vercel appsFast, serverless, JSX templatesVercel-only, limited CSS
Playwright screenshotSelf-hosted, full HTMLAny CSS/font/imageNeeds Chromium binary
SnapAPIAny framework, any hostZero infra, 3 API linesAPI key required

Vercel OG: Next.js App Router

// app/og/route.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'My Blog Post';
  const desc  = searchParams.get('desc')  ?? '';

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          width: '1200px',
          height: '630px',
          background: 'linear-gradient(135deg, #0a0a0f 0%, #1a1040 100%)',
          padding: '60px',
          fontFamily: 'Inter, sans-serif',
        }}
      >
        <div style={{ fontSize: '64px', fontWeight: 700, color: '#fff', lineHeight: 1.1 }}>
          {title}
        </div>
        <div style={{ fontSize: '28px', color: '#9090a8', marginTop: '20px' }}>
          {desc}
        </div>
        <div style={{ fontSize: '22px', color: '#6c63ff', marginTop: '40px' }}>
          myblog.com
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}
// Use in metadata
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  const ogUrl = `/og?title=${encodeURIComponent(post.title)}&desc=${encodeURIComponent(post.excerpt)}`;
  return {
    openGraph: {
      images: [{ url: ogUrl, width: 1200, height: 630 }],
    },
  };
}

Playwright: HTML Template → Screenshot

For full CSS/font/image support beyond what Vercel OG's Satori handles:

import { chromium } from 'playwright';

async function generateOgImage({ title, description, author, tags = [] }) {
  const html = `
    
    
    <head>
      <meta charset="UTF-8">
      <link href="https://fonts.googleapis.com/css2?family=Inter:wght@700;400&display=swap" rel="stylesheet">
      <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
          width: 1200px; height: 630px; overflow: hidden;
          background: linear-gradient(135deg, #0a0a0f, #1a1040);
          font-family: 'Inter', sans-serif;
          display: flex; flex-direction: column; justify-content: flex-end;
          padding: 60px;
        }
        .tags { display: flex; gap: 10px; margin-bottom: 24px; }
        .tag { background: rgba(108,99,255,.2); color: #a78bfa; padding: 4px 12px; border-radius: 20px; font-size: 18px; }
        h1 { font-size: 64px; font-weight: 700; color: #fff; line-height: 1.15; margin-bottom: 20px; }
        .desc { font-size: 28px; color: #9090a8; }
        .author { font-size: 22px; color: #6c63ff; margin-top: 32px; }
      </style>
    </head>
    <body>
      <div class="tags">${tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>
      <h1>${title}</h1>
      <p class="desc">${description}</p>
      <p class="author">${author}</p>
    </body>
    </html>`;

  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setViewportSize({ width: 1200, height: 630 });
  await page.setContent(html, { waitUntil: 'networkidle' });
  const screenshot = await page.screenshot({ type: 'png' });
  await browser.close();
  return screenshot;
}

SnapAPI: OG Image Without Chromium

Host an HTML template at a URL, pass query params, get a screenshot back. No Chromium binary to manage.

// Step 1: Host your OG template at /og-template.html?title=X&desc=Y
// Step 2: Call SnapAPI to screenshot it

async function generateOg(title, description) {
  const templateUrl = encodeURIComponent(
    `https://yourdomain.com/og-template.html?title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}`
  );

  const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: decodeURIComponent(templateUrl),
      width: 1200,
      height: 630,
      format: 'png',
      waitUntil: 'networkidle',
    }),
  });

  return Buffer.from(await res.arrayBuffer());
}

Express OG endpoint with SnapAPI

import express from 'express';

const app = express();

// GET /og?title=Hello+World&desc=My+post
app.get('/og', async (req, res) => {
  const { title = 'Untitled', desc = '' } = req.query;

  const templateUrl = `https://${req.hostname}/og-template.html?title=${encodeURIComponent(title)}&desc=${encodeURIComponent(desc)}`;

  const snap = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url: templateUrl, width: 1200, height: 630, format: 'png' }),
  });

  res.set({
    'Content-Type': 'image/png',
    'Cache-Control': 'public, max-age=86400',  // cache 24 hours
  });
  res.send(Buffer.from(await snap.arrayBuffer()));
});

OG template (og-template.html)

<!-- Static file served at /og-template.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px; height: 630px; overflow: hidden;
      background: linear-gradient(135deg, #0a0a0f, #1a1040);
      font-family: 'Inter', sans-serif;
      display: flex; align-items: center; justify-content: center;
    }
    .card { padding: 60px; }
    h1 { font-size: 64px; font-weight: 700; color: #fff; line-height: 1.2; }
    p  { font-size: 28px; color: #9090a8; margin-top: 20px; }
  </style>
</head>
<body>
  <div class="card">
    <h1 id="title"></h1>
    <p id="desc"></p>
  </div>
  <script>
    const params = new URLSearchParams(location.search);
    document.getElementById('title').textContent = params.get('title') || 'Untitled';
    document.getElementById('desc').textContent  = params.get('desc')  || '';
  </script>
</body>
</html>

Astro: OG Image Endpoint

// src/pages/og/[...slug].png.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ params, request }) => {
  const url = new URL(request.url);
  const title = url.searchParams.get('title') ?? params.slug ?? 'Post';

  const templateUrl = new URL('/og-template', url.origin);
  templateUrl.searchParams.set('title', title);

  const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': import.meta.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url: templateUrl.href, width: 1200, height: 630, format: 'png' }),
  });

  return new Response(await res.arrayBuffer(), {
    headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
  });
};

Caching OG Images

Generating an OG image on every request is wasteful. Cache by content hash:

import { createHash } from 'crypto';
import { existsSync, readFileSync, writeFileSync } from 'fs';

async function getCachedOgImage(title, description) {
  const hash = createHash('md5').update(`${title}:${description}`).digest('hex');
  const cachePath = `./cache/og/${hash}.png`;

  if (existsSync(cachePath)) return readFileSync(cachePath);

  const buffer = await generateOg(title, description);
  writeFileSync(cachePath, buffer);
  return buffer;
}
SnapAPI free tier: 200 captures/month — enough for a small blog. Caching ensures you don't regenerate the same image twice. Get your API key →