Next.js OG Image Generator: Beyond @vercel/og
Next.js ships with @vercel/og, a serverless OG image generator that uses Satori to
render JSX into images. It works — until it doesn't. Hit a CSS feature Satori doesn't support
(grid, Flexbox quirks, custom fonts beyond the basics, transforms) and you're rewriting your
template from scratch.
This guide shows a simpler path: render plain HTML into an image with a screenshot API. You get the full power of CSS, no runtime limits, and it works whether you deploy on Vercel, Netlify, AWS, or your own server.
Why look beyond @vercel/og?
- Satori is a subset of CSS — no
display: grid, limitedposition: absolute, no transforms beyond translate, no::before/::after, no SVG masks. - Edge runtime only — you can't import npm packages that use Node APIs. Got a markdown library? A date formatter? Often broken.
- Font loading is finicky — every custom font requires an explicit fetch + ArrayBuffer.
- Locked into Vercel's runtime — your image generator only runs on Vercel-style edge functions.
If your OG images are simple, @vercel/og is fine. If you want full HTML/CSS, read on.
The plan
- Build a Next.js page that renders your OG template at
/og/[slug] - Hit a screenshot API to capture that page as a 1200×630 PNG
- Cache the result so each post generates its image once
Step 1 — Render the OG template page
Create app/og/[slug]/page.tsx in your Next.js 14+ app:
// app/og/[slug]/page.tsx
import { getPost } from "@/lib/posts";
export default async function OgPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<div style={{
width: 1200,
height: 630,
display: "grid",
gridTemplateRows: "auto 1fr auto",
padding: 60,
background: "linear-gradient(135deg, #0d6efd 0%, #2563eb 100%)",
color: "#fff",
fontFamily: "Inter, system-ui",
}}>
<div style={{ fontSize: 24, opacity: 0.7 }}>myblog.com</div>
<h1 style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.1 }}>
{post.title}
</h1>
<div style={{ fontSize: 28, opacity: 0.8 }}>{post.author} · {post.date}</div>
</div>
);
}
This is regular React with full CSS support — grid, gradients, anything you want.
Step 2 — Capture it with a screenshot API
// app/api/og/[slug]/route.ts
import { Client } from "screenshotapis";
const client = new Client(process.env.SCREENSHOT_API_KEY!);
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const { data } = await client.screenshot({
url: `${process.env.SITE_URL}/og/${params.slug}`,
viewport_width: 1200,
viewport_height: 630,
device_scale_factor: 2, // retina
full_page: false,
});
return new Response(data, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
That's the entire generator. The 1-year cache header means each post's OG image renders once, then serves from CDN forever.
Step 3 — Wire it into metadata
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }): Promise<Metadata> {
return {
openGraph: {
images: [`/api/og/${params.slug}`],
},
twitter: {
card: "summary_large_image",
images: [`/api/og/${params.slug}`],
},
};
}
Done. Every blog post now has a dynamic OG image with full CSS support.
What about cost?
If you publish 10 posts per month and each OG image is generated once and cached for a year, you use 10 renders/month. That's free on most screenshot APIs. Even a busy site rarely exceeds the free tier for OG images.
Caveats
- The OG page must be publicly reachable — the screenshot API needs to fetch it. On Vercel preview deployments, use the deployment URL.
- Authentication is fine — pass an internal token via header if your OG page is gated. Most APIs support custom headers.
- Watch for FOUC — if you have web fonts, the renderer may capture before they load.
Add
delay_ms: 500or use system fonts.
Generate Next.js OG images with full HTML/CSS — 100/month free
Get your API key — free