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

Open Graph images — the thumbnail previews that appear when someone shares your link on Twitter, Slack, or LinkedIn — are one of those features that look simple but quietly eat hours. 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 send you down one of three painful paths: Puppeteer (150 MB dependency, crashes on Vercel), next/og (JSX-only, edge runtime lock-in, limited CSS), or canvas/sharp (no layout engine, pixel math for everything). There is a cleaner approach: send HTML, get a PNG back. That is exactly 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 /og-image endpoint that accepts query parameters and returns a 1200×630 PNG directly:

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

Drop that URL straight into your generateMetadata function:

tsx — app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  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 your node_modules. The image is generated and cached on first request. You can test it in any browser right now — paste the URL and see the result.

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

Option 2: Your own design — full HTML control

When you need your brand colors, custom fonts, or a layout that does not fit the default template, use the /v1/render endpoint. You send raw HTML and get back a binary PNG.

Create a Route Handler in your Next.js app:

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') ?? '';

  const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@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>${title.replace(/[<>]/g, '')}</h1>
  </div>
  <div class="footer">
    <span class="author">${author ? `By ${author.replace(/[<>]/g, '')}` : ''}</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('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 up in your page metadata:

tsx
export async function generateMetadata({ params }) {
  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:

.env.local
RENDERPIX_API_KEY=rpx_your_key_here

The free tier covers 100 renders per month — enough to validate the approach without a credit card. When you are ready to scale, the Starter plan at $9/mo gives you 2,000 renders.

Caching: one render per URL

The s-maxage=86400 header in the Route Handler tells Vercel's Edge Network to cache each unique OG image for 24 hours. After the first visitor hits a URL, every subsequent request is served from cache at zero cost.

For tighter control — or if you are not on Vercel — cache the binary response in Redis or another store keyed by a hash of your inputs:

ts
import { createHash } from 'crypto';

const key = `og:` + createHash('md5')
  .update(title + author)
  .digest('hex');

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

const buf = await renderWithRenderPix(html);
await redis.setex(key, 86400, buf);
return new Response(buf, { headers: { 'Content-Type': 'image/png' } });

How it compares

ApproachWorks on VercelCustom designSetup effortCost
Static PNGYesNoNoneFree
next/og (ImageResponse)YesPartialMediumFree
PuppeteerRarelyFullHighServer cost
RenderPix /og-imageYesTemplateNoneFree tier
RenderPix /v1/renderYesFull HTML/CSSLowFrom $9/mo

Which approach to choose

Start with the /og-image URL approach. It requires zero code and gets you dynamic OG images in 5 minutes. When you need your own branding — custom fonts, logo placement, specific layout — move to the Route Handler with /v1/render and a raw HTML template.

Both approaches run entirely serverlessly, deploy to Vercel without configuration changes, and do not add a single native dependency to your project. The rendered images are identical to what a real Chromium browser would produce, because that is exactly what generates 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 →