Running a full browser instance like Puppeteer or Playwright just to generate a 1200x630 social card is a bad idea. Cold start times routinely exceed three seconds, memory consumption spikes past 512MB per instance, and infrastructure bills get out of hand quickly. If your site has thousands of blog posts or dynamic user profiles, rendering social previews on-demand with headless browsers creates massive performance bottlenecks.
Next.js 16 solves this with ImageResponse, which runs on Vercel's Edge Runtime or any Cloudflare Workers-compatible environment. Under the hood, it pairs Vercel's Satori engine with a WebAssembly build of Resvg. Instead of launching Chrome, Satori parses a subset of HTML and inline CSS, converts it into an SVG vector graph, and Resvg compiles that SVG directly into a PNG byte stream. Execution times drop from 2000ms to under 40ms.
The App Router File Convention
In Next.js 16 App Router, you don't need to manually configure HTML meta tags for Open Graph images. Placing an opengraph-image.tsx file directly inside a route folder automatically creates the required route and injects the corresponding <meta property="og:image"> tags into your page head.
Here is a complete, production-ready implementation that fetches post metadata from a Laravel 12 API backend running PHP 8.3 and renders a dynamic PNG card on the Edge:
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'Article Open Graph Card';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Load a custom font from a local file as ArrayBuffer
const fontData = await fetch(
new URL('./Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
// Fetch article data from Laravel 12 backend
const res = await fetch(`https://api.techversedaily.com/api/v1/posts/${slug}`, {
headers: { Accept: 'application/json' },
next: { revalidate: 3600 },
});
if (!res.ok) {
return new ImageResponse(
(
<div style={fallbackStyle}>
<span style={{ fontSize: 60, fontWeight: 700, color: '#ffffff' }}>
Tech Verse Daily
</span>
</div>
),
{ ...size }
);
}
const post = await res.json();
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
backgroundColor: '#090d16',
padding: '80px',
fontFamily: 'Inter',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: '16px', height: '16px', borderRadius: '50%', backgroundColor: '#38bdf8' }} />
<span style={{ fontSize: '24px', color: '#94a3b8', fontWeight: 700 }}>
Tech Verse Daily
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ fontSize: '56px', fontWeight: 700, color: '#f8fafc', lineHeight: 1.1 }}>
{post.title}
</div>
<div style={{ fontSize: '28px', color: '#94a3b8', lineHeight: 1.4 }}>
{post.excerpt}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #1e293b', paddingTop: '32px' }}>
<span style={{ fontSize: '20px', color: '#64748b' }}>By {post.author}</span>
<span style={{ fontSize: '20px', color: '#38bdf8' }}>{post.read_time} min read</span>
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
);
}
const fallbackStyle = {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#090d16',
};Font Loading and the WOFF2 Catch
The single biggest gotcha developers encounter with Satori is font handling. If you pass a standard web font path or rely on system fonts, your image generation breaks or falls back to generic serif rendering. Satori requires raw font binary data passed as an ArrayBuffer.
WOFF2 fonts aren't supported out of the box because the decompression overhead increases the WebAssembly bundle size beyond Edge worker limits. You must supply .ttf or .otf files instead. If you load Google Fonts dynamically at runtime, ensure your fetch request specifically targets TrueType format URLs by providing an older browser User-Agent header in the request.
const fontResponse = await fetch(
'https://fonts.googleapis.com/css2?family=Inter:wght@700&text=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
{
headers: {
// User agent that forces Google Fonts to return TTF instead of WOFF2
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
}
);Layout Constraints and CSS Subsets
Satori isn't a full CSS engine. It uses Yoga, Facebook's cross-platform Flexbox layout engine. That brings strict rules you must follow when designing cards with React 19 components:
- Flexbox only: CSS Grid is completely unsupported. Every layout container must use
display: flex. - Explicit dimensions: Satori won't reliably infer sizes for deeply nested items. Set explicit
widthandheightpercentages or pixel values where layout boundaries matter. - No CSS Variables: Native custom properties (like
var(--primary)) are ignored. Use plain JavaScript variables or raw hex strings inside your inline style objects. - Limited properties: Complex box shadows, CSS filters, and clip paths will throw warnings or fail to render. Stick to clean background colors, simple borders, and typography properties.
Caching Generated Cards at the Edge
Social media crawlers like Twitterbot and LinkedInBot crawl URLs frequently. Generating an image dynamically on every single request wastes compute cycles. You need a caching layer so edge network nodes cache the PNG output after the initial render.
By default, dynamic API routes in Next.js App Router return default headers that prevent aggressive browser caching. To override this, explicitly set custom Cache-Control response headers inside standalone image route handlers.
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') ?? 'Tech Verse Daily';
const category = searchParams.get('category') ?? 'Engineering';
const image = new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
backgroundColor: '#020617',
padding: '60px',
color: '#f8fafc',
}}
>
<span style={{ fontSize: '20px', color: '#38bdf8', textTransform: 'uppercase' }}>
{category}
</span>
<span style={{ fontSize: '52px', fontWeight: 800, marginTop: '20px' }}>
{title}
</span>
</div>
),
{ width: 1200, height: 630 }
);
// Cache the image at CDN edge for 1 year, revalidate in background
image.headers.set(
'Cache-Control',
'public, max-age=31536000, s-maxage=31536000, stale-while-revalidate=86400, immutable'
);
return image;
}Integration with Laravel 12 Backends
When running Next.js 16 alongside a Laravel 12 backend on PHP 8.3, keep dynamic payload sizes tiny. The Edge worker shouldn't parse massive JSON trees just to extract three strings for a graphic. Create a dedicated internal endpoint in Laravel, such as /api/v1/posts/{slug}/og-data, returning only key-value pairs for title, author, and category.
Keeping API responses under 2KB ensures that the fetch roundtrip stays under 15ms. Combined with Satori's 25ms execution time, your total image render budget stays comfortably below 50ms before the CDN catches and serves subsequent requests globally.









