Generating Social Cards in Next.js Without Vercel Lock-in

If you deploy Next.js on Netlify, Railway, Fly.io, or a VPS, @vercel/ogcauses friction. It was built for Vercel's Edge Runtime and its Satori dependency pulls in WebAssembly blobs that break on other runtimes or add 300KB to your function bundle. For a feature that runs once per page load, that tradeoff rarely makes sense.
The alternative: call a rendering API from your route handler. Pass HTML, get back a PNG. No WASM, no runtime lock-in, works anywhere Next.js runs.
Prerequisites
- Next.js 14 or 15 (App Router)
- A Rendex API key (free tier, 100 calls/month; get one at rendex.dev/login)
- Your API key stored in
.env.localasRENDEX_API_KEY=rdx_your_key
Step 1: Create the OG Image Route
Create a route handler that accepts page metadata as query parameters and returns a PNG image. This route becomes the og:image URL for every page.
import { NextRequest } from "next/server"
export const runtime = "nodejs" // works on any host, not Edge-only
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const title = searchParams.get("title") ?? "My Site"
const description = searchParams.get("description") ?? ""
const html = buildOgHtml(title, description)
const res = await fetch("https://api.rendex.dev/v1/screenshot", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RENDEX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
format: "png",
width: 1200,
height: 630,
}),
})
if (!res.ok) {
return new Response("Failed to generate image", { status: 500 })
}
const buffer = await res.arrayBuffer()
return new Response(buffer, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
})
}
function buildOgHtml(title: string, description: string): string {
return `
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
background: #0f0f0f;
font-family: system-ui, -apple-system, sans-serif;
color: #fff;
}
.tag {
font-size: 18px;
color: #888;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 24px;
}
h1 {
font-size: 64px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 24px;
max-width: 900px;
}
p {
font-size: 28px;
color: #aaa;
max-width: 800px;
line-height: 1.4;
}
.brand {
position: absolute;
bottom: 48px;
right: 80px;
font-size: 20px;
color: #555;
}
</style>
</head>
<body>
<div class="tag">yoursite.com</div>
<h1>${title}</h1>
<p>${description}</p>
<div class="brand">yoursite.com</div>
</body>
</html>
`
}Step 2: Wire Up Metadata
Point each page's openGraph.imagesat the route handler, passing the page's title and description as query params.
import type { Metadata } from "next"
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) return {}
const ogUrl = new URL("https://yoursite.com/og")
ogUrl.searchParams.set("title", post.title)
ogUrl.searchParams.set("description", post.description)
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [
{
url: ogUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
images: [ogUrl.toString()],
},
}
}Step 3: Add a Site-Wide Default
Set a static fallback in your root layout.tsx for pages that do not generate custom metadata:
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
openGraph: {
images: [
{
url: "/og?title=My+Site&description=The+tagline+goes+here",
width: 1200,
height: 630,
},
],
},
}Step 4: Cache the Generated Images
The route handler adds Cache-Control: public, max-age=86400 headers. CDN and browser caches hold the image for 24 hours. For content that changes rarely (blog posts, docs pages), increase the max-age:
// For evergreen content — cache for 7 days
"Cache-Control": "public, max-age=604800, s-maxage=604800, stale-while-revalidate=86400"If a page's metadata changes (title edit, description update), the query string changes and the CDN treats it as a new URL. No manual cache invalidation needed.
Step 5: Test Before Deploying
Verify the Rendex API can render your HTML before wiring it into Next.js. Paste your HTML template into the HTML-to-image tool for a quick preview, or use curl:
curl -X POST https://api.rendex.dev/v1/screenshot \
-H "Authorization: Bearer rdx_your_key" \
-H "Content-Type: application/json" \
-d '{
"html": "<html><body style=\"background:#000;color:#fff;width:1200px;height:630px;display:flex;align-items:center;justify-content:center;font-size:64px;font-family:system-ui\">Hello OG</body></html>",
"format": "png",
"width": 1200,
"height": 630
}' --output test-og.png && open test-og.pngGet your API key at rendex.dev/login. The free tier gives you 100 renders/month, more than enough for testing.
Troubleshooting
Image shows as broken link in social previews: Social crawlers require the URL to return an image directly (not a redirect). Make sure your route handler returns the PNG bytes with Content-Type: image/png, not a JSON response wrapping a URL.
Text is cut off or overflows: The rendered viewport is exactly 1200x630 pixels. If your title is long, reduce the font size or add overflow: hidden to your container. Test long titles explicitly using the OG preview tool before deploying.
Route returns 500 in production: Check that RENDEX_API_KEYis set in your host's environment variable config. On Netlify, this goes in Site Configuration > Env Vars. On Railway, it goes in the service Variables tab.
Custom fonts are not loading: Use system fonts (system-ui, sans-serif) or inline a base64 font face in your HTML. The Rendex API renders the HTML in isolation. Fonts must be inlined or referenced by absolute public URL.
How This Compares to @vercel/og
| Dimension | @vercel/og | Rendex API |
|---|---|---|
| Hosting requirement | Vercel Edge (Satori + WASM) | Any host, any runtime |
| Template language | JSX (limited CSS subset) | Full HTML + CSS + JS |
| CSS support | Subset (no Grid, no clip-path) | Full Chromium CSS |
| Bundle size impact | ~300KB WASM | None (external API call) |
| Caching | CDN via Vercel | You set Cache-Control headers |
The main trade-off: @vercel/og runs locally in your function and has no per-call cost. The API approach adds a network hop per unique image but removes all runtime constraints. For teams already off Vercel, the API is the simpler path.
For more on generating dynamic images and the full parameter reference, see the Rendex API reference and the dynamic OG images guide, which covers Astro and Nuxt integrations alongside Next.js.
Next Steps
Try the HTML-to-image tool to preview your OG template without writing any code. When your design is ready, get a free API key and drop the route handler into your Next.js project.