d simple layouts.
Implementation:
// app/api/social-card/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
// Define card dimensions per Open Graph standards
const CARD_DIMENSIONS = { width: 1200, height: 630 };
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const headline = searchParams.get('headline') ?? 'Default Headline';
const author = searchParams.get('author') ?? 'Anonymous';
// Optional: Load custom font for consistent rendering
const fontData = await fetch(
new URL('/assets/inter-bold.ttf', request.url)
).then((res) => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
backgroundColor: '#111827',
color: '#f9fafb',
fontFamily: 'Inter, sans-serif',
padding: '40px',
boxSizing: 'border-box',
}}
>
<h1
style={{
fontSize: 64,
fontWeight: 700,
margin: 0,
textAlign: 'center',
lineHeight: 1.1,
}}
>
{headline}
</h1>
<p style={{ fontSize: 32, color: '#9ca3af', marginTop: 24 }}>
By {author}
</p>
</div>
),
{
...CARD_DIMENSIONS,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
);
}
Pattern 2: Screenshot-Based Generation via External API
When your social card must mirror complex UI componentsâsuch as interactive charts, data tables, or layouts relying on CSS features Satori lacksâyou need a full browser rendering pipeline. This pattern involves creating a dedicated route that renders the card visually, then using an external service to capture a screenshot.
Architecture Rationale:
- Rendering: Uses a headless browser to render a real DOM. This guarantees pixel-perfect parity with your application's UI.
- Latency: Higher due to browser spin-up, network requests, and rasterization.
- Best For: Dashboards, data visualizations, complex grids, or when design fidelity is non-negotiable.
Implementation:
// lib/render-service.ts
export interface ScreenshotOptions {
viewportWidth: number;
viewportHeight: number;
format: 'png' | 'jpeg';
}
export async function captureSocialCard(
targetUrl: string,
options: ScreenshotOptions
): Promise<Buffer> {
const apiKey = process.env.RENDER_API_KEY;
if (!apiKey) throw new Error('Render API key missing');
const response = await fetch('https://api.render-provider.io/v1/capture', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: targetUrl,
viewport: {
width: options.viewportWidth,
height: options.viewportHeight,
},
format: options.format,
wait_until: 'networkidle',
}),
});
if (!response.ok) {
throw new Error(`Capture failed: ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
// app/api/social-card/complex/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { captureSocialCard } from '@/lib/render-service';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug');
if (!slug) return NextResponse.json({ error: 'Slug required' }, { status: 400 });
// The target route must exist solely for rendering the card
const renderUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/render/preview/${slug}`;
try {
const imageBuffer = await captureSocialCard(renderUrl, {
viewportWidth: 1200,
viewportHeight: 630,
format: 'png',
});
return new NextResponse(imageBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, immutable',
},
});
} catch (error) {
// Fallback logic should be implemented here
return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
}
}
Pattern 3: Hybrid Middleware-Triggered Generation
The hybrid pattern routes requests dynamically based on content characteristics. A middleware layer inspects the request and directs simple requests to the edge generator while routing complex requests to the screenshot service.
Architecture Rationale:
- Efficiency: Keeps the fast path fast. Only pays the latency cost for routes that require it.
- Complexity: Requires logic to classify routes and maintain two generation pipelines.
- Best For: Large-scale applications with mixed content types (e.g., a blog with simple posts and a dashboard with shareable reports).
Implementation:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Define patterns that require full browser rendering
const COMPLEX_ROUTES = ['/api/social-card/report', '/api/social-card/dashboard'];
export function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
// Check if the request targets a complex route
const isComplex = COMPLEX_ROUTES.some((route) => path.startsWith(route));
if (isComplex) {
// Rewrite to the screenshot handler
return NextResponse.rewrite(new URL('/api/social-card/screenshot-handler', request.url));
}
// Default to edge handler
return NextResponse.next();
}
export const config = {
matcher: '/api/social-card/:path*',
};
Pitfall Guide
Production deployments of dynamic OG images frequently encounter specific failure modes. Avoid these common mistakes.
| Pitfall | Explanation | Fix |
|---|
| Font Loading Failures | Satori requires font data to render text correctly. If fonts are not fetched and passed to ImageResponse, text may render with fallback fonts or fail entirely. | Always fetch font buffers in the route handler and pass them via the fonts option. Cache font data if possible. |
| Cache Key Fragmentation | Caching based on full URLs with random query parameters (e.g., ?ts=123) prevents cache hits, causing repeated generation and latency spikes. | Normalize cache keys by stripping non-essential parameters. Cache by content slug or ID, not by request URL. |
| Silent Generation Failures | If the generation endpoint throws a 500 error, social platforms may display no image or a broken icon, degrading the share experience. | Implement error boundaries that return a static fallback image on failure. Never return a 5xx status for an image request. |
| Screenshot Cold Starts | Screenshot APIs often run on serverless infrastructure. Infrequent requests can trigger cold starts, adding seconds of latency. | Pre-generate images for high-value content at build or publish time. Use keep-alive strategies for low-traffic routes if necessary. |
| CSS Divergence | Relying on Satori for layouts that use unsupported CSS features results in cards that look broken or misaligned compared to the app. | Audit your card designs against Satori's supported CSS list. Use the hybrid pattern to route unsupported layouts to screenshots. |
| Missing Meta Tags | Generating the image is useless if the HTML <head> does not reference the dynamic URL. | Ensure every dynamic page sets <meta property="og:image" content="..." /> pointing to the generation endpoint with correct parameters. |
| Ignoring Crawler Timeouts | Social platforms have varying timeout limits. If your endpoint exceeds these, the image is dropped. | Optimize generation time. For screenshots, ensure the target route renders quickly. Use aggressive caching to serve subsequent requests instantly. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Blog Posts / Articles | Edge / Satori | Simple text layouts, high traffic, low latency required. | Low (No external costs) |
| User Dashboards | Screenshot API | Complex charts, data tables, full CSS needed. | Medium (API usage fees) |
| E-commerce Products | Hybrid | Simple product cards via Edge; complex comparison views via Screenshot. | Variable |
| Marketing Landing Pages | Static Image | Content rarely changes; pre-generated is most efficient. | None |
| Real-time Data Feeds | Screenshot API | Requires live data rendering; edge cannot access dynamic state easily. | High (Frequent generation) |
Configuration Template
Use this template to configure cache headers and error handling in your Next.js routes.
// app/api/social-card/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
try {
// ... generation logic ...
return new ImageResponse(
// ... JSX ...
{ width: 1200, height: 630 }
);
} catch (error) {
console.error('OG Image generation failed:', error);
// Return fallback image
const fallbackUrl = new URL('/assets/fallback-og.png', request.url);
const fallbackResponse = await fetch(fallbackUrl);
const fallbackBuffer = await fallbackResponse.arrayBuffer();
return new Response(fallbackBuffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
}
}
Quick Start Guide
- Install Dependencies: Run
npm install next @vercel/og to add the core libraries to your project.
- Create Route: Add a new file at
app/api/social-card/route.tsx with the Edge runtime configuration.
- Design Card: Implement the JSX layout for your social card, ensuring CSS properties are compatible with Satori.
- Add Meta Tags: Update your page components to include
<meta property="og:image" content={...} /> pointing to your new route with dynamic parameters.
- Test Locally: Start the dev server and visit the route URL to verify image generation. Use a social debugger tool to validate the output.