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.
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:
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:
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.
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:
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:
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, }], }, }; }
Sign up at renderpix.dev and copy your API key from the dashboard. Add it to .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.
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:
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' } });
| Approach | Works on Vercel | Custom design | Setup effort | Cost |
|---|---|---|---|---|
| Static PNG | Yes | No | None | Free |
| next/og (ImageResponse) | Yes | Partial | Medium | Free |
| Puppeteer | Rarely | Full | High | Server cost |
| RenderPix /og-image | Yes | Template | None | Free tier |
| RenderPix /v1/render | Yes | Full HTML/CSS | Low | From $9/mo |
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.
Get a free API key, try the live playground, and see your first render in under a minute.