How to Generate OG Images in Next.js with a Single API Call

Open Graph images — the preview thumbnails that appear when someone shares your link on Twitter, Slack, or LinkedIn — look simple but quietly eat hours to implement properly. A static og.png works for a homepage, but the moment you have blog posts, product pages, or user profiles, you need a unique image per URL with the right title baked in.

Most tutorials funnel you toward one of three painful options: Puppeteer (150 MB dependency, crashes on Vercel), next/og (JSX-only, edge runtime lock-in, limited CSS support), or canvas/sharp (no layout engine — pure pixel math). There is a cleaner approach: send HTML, get a PNG back. That is what renderpix does.

Option 1: Zero code — a URL is enough

For most blog setups you do not need a Route Handler at all. renderpix exposes a public /og-image endpoint that accepts query parameters and returns a 1200×630 PNG directly. Paste this in your browser to see it work immediately:

bash
https://renderpix.dev/og-image?title=My+Post+Title&desc=A+short+description&domain=myblog.com

Drop that URL straight into your generateMetadata function:

tsx — app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  const ogUrl = new URL('https://renderpix.dev/og-image');
  ogUrl.searchParams.set('title',  post.title);
  ogUrl.searchParams.set('desc',   post.excerpt);
  ogUrl.searchParams.set('domain', 'myblog.com');

  return {
    title: post.title,
    openGraph: {
      images: [{ url: ogUrl.toString(), width: 1200, height: 630 }],
    },
  };
}

No build step, no Route Handler, no Puppeteer in node_modules. The image is generated and cached on first request. Subsequent requests are served from the CDN edge at zero additional cost.

The /og-image endpoint works without an API key (rate-limited to 10 req/min per IP) and adds a small watermark. For production traffic, pass your key via &api_key=rpx_... or the X-Api-Key header to remove the watermark and unlock higher limits.

Option 2: Full HTML control — your own design

When you need custom brand colors, specific fonts, or a layout that does not fit the default template, use the /v1/render endpoint. You POST raw HTML and receive a binary PNG. This is how most production integrations work.

Create a Route Handler:

ts — app/og/route.ts
import { NextRequest } from 'next/server';

export async function GET(req: NextRequest) {
  const title  = req.nextUrl.searchParams.get('title')  ?? 'Untitled';
  const author = req.nextUrl.searchParams.get('author') ?? '';

  // Sanitize to prevent HTML injection in the image template
  const safe = (s: string) => s.replace(/[<>&"]/g, '');

  const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    width: 1200px; height: 630px; overflow: hidden;
    background: #09090b; font-family: 'Inter', sans-serif;
    display: flex; align-items: center; justify-content: center;
  }
  .card {
    width: 1100px; height: 530px; background: #141416;
    border: 1px solid #27272a; border-radius: 20px;
    padding: 64px; display: flex; flex-direction: column;
    justify-content: space-between;
  }
  .eyebrow { color: #22d3ee; font-size: 14px; font-weight: 700;
    letter-spacing: .08em; text-transform: uppercase; }
  h1 { font-size: 52px; color: #f4f4f5; line-height: 1.15;
    margin-top: 18px; max-width: 880px; }
  .footer { display: flex; align-items: center; justify-content: space-between; }
  .author { color: #71717a; font-size: 18px; }
  .brand  { color: #3f3f46; font-size: 14px; font-weight: 700; letter-spacing: .06em; }
</style></head>
<body><div class="card">
  <div>
    <div class="eyebrow">myblog.com</div>
    <h1>${safe(title)}</h1>
  </div>
  <div class="footer">
    <span class="author">${author ? `By ${safe(author)}` : ''}</span>
    <span class="brand">MYBLOG.COM</span>
  </div>
</div></body></html>`;

  const res = await fetch('https://renderpix.dev/v1/render', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': process.env.RENDERPIX_API_KEY!,
    },
    body: JSON.stringify({ html, width: 1200, height: 630, format: 'png' }),
  });

  if (!res.ok) {
    return new Response('Render failed', { status: 500 });
  }

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

Wire it into your page metadata:

tsx — app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return {
    openGraph: {
      images: [{
        url: `/og?title=${encodeURIComponent(post.title)}&author=${encodeURIComponent(post.author)}`,
        width: 1200,
        height: 630,
      }],
    },
  };
}

Setting up your API key

Sign up at renderpix.dev and copy your API key from the dashboard. Add it to .env.local (never commit this file):

.env.local
RENDERPIX_API_KEY=rpx_your_key_here

On Vercel, add it under Settings → Environment Variables. The free tier covers 100 renders per month — enough to validate the setup without a credit card. The Starter plan ($9/mo) gives you 2,000 renders per month.

Caching: render once, serve forever

The Route Handler is a normal GET endpoint, so standard HTTP caching applies. The s-maxage=86400 response header tells Vercel's Edge Network to cache each unique OG image for 24 hours. After the first visitor hits /og?title=…, every subsequent request is served from the CDN — your Route Handler does not run again until the TTL expires.

If you are not on Vercel, or you want explicit control over invalidation, cache the rendered PNG in Redis keyed by a hash of your inputs:

ts
import { createHash } from 'node:crypto';

const cacheKey = 'og:' + createHash('sha1')
  .update(title + '|' + author)
  .digest('hex');

const cached = await redis.getBuffer(cacheKey);
if (cached) {
  return new Response(cached, {
    headers: { 'Content-Type': 'image/png', 'X-Cache': 'HIT' },
  });
}

const buf = Buffer.from(await renderRes.arrayBuffer());
await redis.setex(cacheKey, 86400, buf);

return new Response(buf, {
  headers: { 'Content-Type': 'image/png', 'X-Cache': 'MISS' },
});

How it compares

Approach Works on Vercel Custom design Setup effort Cost
Static PNG Yes No — one image for all pages None Free
next/og (ImageResponse) Yes Partial — JSX + Tailwind only Medium Free
Puppeteer / Playwright Rarely Full HTML/CSS High — large binary, cold starts Self-hosted server cost
renderpix /og-image Yes Built-in template None — URL only Free tier available
renderpix /v1/render Yes Full HTML/CSS Low — one Route Handler From $9/mo

Which approach to pick

Start with the /og-image URL approach. It requires zero code changes and gives you dynamic, per-page OG images in under five minutes. When you outgrow the default template — custom logo, brand colors, specific typography — switch to the Route Handler with /v1/render and a raw HTML template you fully control.

Both approaches are serverless, deploy to Vercel without configuration changes, and add zero native dependencies to your project. The rendered images are pixel-identical to what a real Chromium browser would produce — because that is exactly what renders them.

Ready to add OG images to your Next.js app?

Get a free API key, try the live playground, and see your first render in under a minute.

← All articles API Reference →