Use case · OG images

Ship Open Graph images that look like the rest of your site.

Full HTML/CSS, any font, any layout. Render at build time from your CI, at runtime from a Next.js route handler, or get a cached hosted URL. Drop into <meta property="og:image" /> and ship.

The problem

OG images are a tax on every page you publish. Hand-design one per post in Figma? Inconsistent and slow. Skip them entirely? Your Twitter and LinkedIn previews look broken next to competitors.

The usual fix — @vercel/og— works on Vercel but locks you into a React subset (no third-party UI libs, limited CSS, font workarounds). Anywhere else, you're writing Puppeteer glue yourself.

Three approaches, one API

1. Build-time (CLI)

For static content

Run @codetoimage/cli in GitHub Actions, write PNGs to /public/og/, commit them or let your host's build cache. CDN-served, zero runtime cost.

2. Runtime (API, inline bytes)

For dynamic content

Next.js route handler at /og/[slug]/route.ts proxies a POST to /v1/render, streams the PNG back. Plays well with revalidate.

3. Hosted URL (CDN, 24h TTL)

For meta tags

Pass output: "url", get back a CDN-served hosted URL. Store it on the post record, embed directly. Your server is never in the image-serving path.

Next.js example — dynamic OG per route

App router, route handler, full HTML/CSS template. Replace @vercel/og with a server fetch to /v1/render:

// app/og/[slug]/route.ts
import { NextRequest } from "next/server";

export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  const html = `
    <div style="
      width: 1200px; height: 630px;
      display: flex; flex-direction: column; justify-content: space-between;
      padding: 80px;
      background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
      color: white; font-family: 'Inter', sans-serif;
    ">
      <div style="font-size: 24px; opacity: 0.6;">${post.category}</div>
      <h1 style="font-size: 72px; line-height: 1.1; font-weight: 700;">
        ${post.title}
      </h1>
      <div style="display: flex; gap: 16px; align-items: center; font-size: 22px;">
        <span>${post.author}</span> · <span>${post.date}</span>
      </div>
    </div>`;

  const res = await fetch("https://api.codetoimage.app/v1/render", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.CODETOIMAGE_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, width: 1200, height: 630, format: "png" }),
    next: { revalidate: 3600 },
  });

  return new Response(res.body, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=3600, s-maxage=86400",
    },
  });
}

In your page metadata, reference it: openGraph: { images: ['/og/' + slug] }. Caches at CDN edge for 24h, regenerates server-side hourly.

Build-time alternative — CLI in CI

When OG content is fixed at deploy time (blog post titles in MDX, doc page headings), generate PNGs once during build:

# .github/workflows/og.yml
name: og-images
on: { push: { branches: [main], paths: ['content/**'] } }
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - name: Generate OG per MDX file
        run: |
          for f in content/posts/*.mdx; do
            slug=$(basename "$f" .mdx)
            title=$(grep -oP '(?<=^title: ).*' "$f")
            npx @codetoimage/cli render \
              --html "<div style='...'>${title}</div>" \
              -o "public/og/${slug}.png" \
              -w 1200 -h 630
          done
        env:
          CODETOIMAGE_API_KEY: ${{ secrets.CODETOIMAGE_API_KEY }}
      - run: git add public/og && git diff --cached --quiet || \
             (git commit -m "chore: regenerate og" && git push)

vs @vercel/og

Aspect@vercel/ogcodetoimage
RendererSatori (React subset → SVG)Headless Chromium (full HTML/CSS)
CSS supportFlexbox + limited subset✓ Full CSS (grid, animations as static frames, transforms, filters)
Custom fontsManual ArrayBuffer import per weight✓ Any @import or @font-face
Speed✓ ~150ms edge cold~600ms median
Hosting requirementVercel Edge (technically also Node)✓ Any host — server, Lambda, container, CLI
Hosted URL outputNo✓ CDN-served, 24h TTL
Bundle size in your app~1 MB (Satori + fonts)✓ Zero — it's a fetch

What you get

Per-route OG images

One template, infinite variants. Slug + title + author → unique PNG. CDN-cached after first hit.

Dark/light variants

Pass a theme param, branch the CSS. Two URLs per post for users browsing in different modes.

Branded social cards

Use your real fonts, real colors, real component design system. Not the Satori-flavored approximation.

GitHub Action regeneration

Trigger on content change in MDX, commit the new PNGs back to /public. Vercel/Netlify serves them at the edge.

Drop-in for existing builds

No framework lock-in. SvelteKit, Astro, Eleventy, Hugo, Jekyll — all of them can shell out to the CLI or curl the API.

No React/JSX limits

Want CSS grid? Tailwind 4? Custom SVG with filters? A WebFont CDN? Whatever renders in Chrome renders here.

FAQ

Does this replace @vercel/og?

It's a flexible alternative. @vercel/og uses Satori under the hood, which renders a React subset to SVG — fast and edge-friendly, but you can't use arbitrary CSS, custom fonts beyond imports, or any third-party library output. codetoimage runs full headless Chromium, so any HTML/CSS that renders in a real browser works here. Trade-off: ~600ms median vs @vercel/og's ~150ms on the edge.

What size should an OG image be?

1200×630 is the safe default — supported by Open Graph (Facebook, LinkedIn) and Twitter (renders as large summary card). Some platforms accept up to 1200×675 (16:9). Twitter's minimum is 600×314. We default to 1200×630 with format PNG.

Build-time or runtime — which approach should I use?

Build-time (CLI in CI) is best when content is static and changes only on deploy — your OG images live in /public and Vercel/Netlify caches them at the CDN edge for free. Runtime (API call from a Next.js route handler) is best when content is dynamic — user-generated profiles, real-time stats, A/B tests. Hosted URL mode is the middle ground: generate once, get a cached URL, embed in meta tags without your server in the path.

How do I update OG images without rebuilding the whole site?

Use runtime API mode with output: "url" — the response gives you a 24h CDN-served URL. Re-fetch on a cron, write the new URL into your CMS meta field. Or set up a webhook from your CMS that calls /v1/render on publish, stashing the URL in the post record. No deploy needed.

Stop hand-designing OG images.

Free Sandbox tier — 50 renders/month, no credit card. Wire up in five minutes with the Next.js example above.