Generating Colorful OG Images with Next.js and Satori
When someone shares a URL on Twitter, LinkedIn, or Slack, the Open Graph image is often the first thing a reader sees. A card with a generic white background and black text blends into the feed. A card with a bold gradient, a precisely matched brand color, and content tailored to the specific page stops the scroll.
The technical challenge is that OG images cannot be interactive â they are static images fetched by crawlers and preview renderers, not browsers. For a site with thousands of pages, generating a unique image for every URL at build time is impractical. The solution is generating them dynamically, on request, with the same performance characteristics as a cached API response.
Next.js and Satori, together, make this possible in a few dozen lines of code.
Why Custom OG Images Matter for SEO
OG image quality does not directly affect Google's ranking algorithm, but it has a measurable indirect effect through click-through rate. A page with a compelling preview image earns more clicks from the same impression count â and CTR is a signal that search engines interpret as quality.
For color-related content in particular, the OG image is an opportunity to demonstrate what the page is about before the user clicks. A page about #FF5733 should show that color prominently. A gradient article should show gradients. The image should communicate the content, not just the brand.
Additionally, compelling OG images improve link preview quality on:
- Twitter/X (fetches og:image tags)
- LinkedIn (uses og:image with preference for 1200Ã627)
- Slack (renders link unfurls from OG meta)
- Discord (same)
- iMessage and WhatsApp rich previews
A single well-implemented OG image route serves all of them.
Satori and @vercel/og Setup
What Satori Does
Satori is a library from Vercel that converts a React-like JSX tree into an SVG string â without a browser, without a DOM, and without Puppeteer. It implements a subset of CSS (flexbox layout, basic typography, gradients, border-radius) sufficient for image generation.
@vercel/og wraps Satori and adds PNG conversion (via a WebAssembly resvg build), making it a single-import solution for the Edge Runtime or Node.js.
Installation
npm install @vercel/og
# or
pnpm add @vercel/og
Route Handler Setup (App Router)
In Next.js App Router, create a Route Handler at app/og/route.tsx:
// app/og/route.tsx
import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge'; // Runs at the CDN edge, lowest latency
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') ?? 'ColorFYI';
const hex = searchParams.get('hex') ?? 'FF5733';
const color = `#${hex.replace('#', '')}`;
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: color,
}}
>
<h1 style={{ color: '#FFFFFF', fontSize: 72 }}>{title}</h1>
</div>
),
{
width: 1200,
height: 630,
}
);
}
Access at: https://yoursite.com/og?title=Coral+Red&hex=FF5733
This minimal example works but is not yet production quality. The sections below build it into a real implementation.
Dynamic Color in OG Image Templates
Automatic Text Color from Background
When the background color is user-determined or dynamic, the text color needs to adapt. Light backgrounds need dark text; dark backgrounds need light text. This is the same relative luminance calculation used for WCAG contrast:
// lib/og-color.ts
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '');
return [
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16),
];
}
function relativeLuminance(hex: string): number {
const [r, g, b] = hexToRgb(hex).map(v => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function bestTextColor(backgroundHex: string): '#000000' | '#FFFFFF' {
return relativeLuminance(backgroundHex) > 0.179 ? '#000000' : '#FFFFFF';
}
export function hexToHsl(hex: string): [number, number, number] {
const [r, g, b] = hexToRgb(hex).map(v => v / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return [0, 0, Math.round(l * 100)];
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
default: h = ((r - g) / d + 4) / 6;
}
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
}
A Full Color Page Template
A rich OG template for a color detail page â showing the color prominently, its hex code, and a color chip:
// app/og/route.tsx
import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';
import { bestTextColor, hexToHsl } from '../../lib/og-color';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const rawHex = searchParams.get('hex') ?? 'FF5733';
const name = searchParams.get('name') ?? '';
const hex = `#${rawHex.replace('#', '').toUpperCase()}`;
const textColor = bestTextColor(hex);
const [h, s, l] = hexToHsl(hex);
// Complementary hue for accent elements
const compHue = (h + 180) % 360;
const compColor = `hsl(${compHue}, ${Math.min(s, 60)}%, ${Math.max(20, Math.min(80, l))}%)`;
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: hex,
padding: '60px',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
{/* Left panel: color info */}
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flex: 1,
}}
>
{/* Site name */}
<span
style={{
color: textColor,
fontSize: 24,
fontWeight: 700,
opacity: 0.7,
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
ColorFYI
</span>
{/* Main color name / hex */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{name && (
<span style={{ color: textColor, fontSize: 36, fontWeight: 500, opacity: 0.9 }}>
{name}
</span>
)}
<span
style={{
color: textColor,
fontSize: 96,
fontWeight: 800,
letterSpacing: '-0.02em',
lineHeight: 1,
}}
>
{hex}
</span>
<div style={{ display: 'flex', gap: 24 }}>
<span style={{ color: textColor, fontSize: 24, opacity: 0.7 }}>
rgb({parseInt(rawHex.slice(0, 2), 16)}, {parseInt(rawHex.slice(2, 4), 16)}, {parseInt(rawHex.slice(4, 6), 16)})
</span>
<span style={{ color: textColor, fontSize: 24, opacity: 0.7 }}>
hsl({h}°, {s}%, {l}%)
</span>
</div>
</div>
{/* Footer: site tagline */}
<span style={{ color: textColor, fontSize: 20, opacity: 0.5 }}>
colorfyi.com â Explore 16.7 million colors
</span>
</div>
{/* Right panel: color chips */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
width: 120,
alignSelf: 'center',
}}
>
{[0.15, 0.3, 0.5, 0.7, 0.85].map((t) => {
// Interpolate between white and the target color
const r = Math.round(255 * (1 - t) + parseInt(rawHex.slice(0, 2), 16) * t);
const g = Math.round(255 * (1 - t) + parseInt(rawHex.slice(2, 4), 16) * t);
const b = Math.round(255 * (1 - t) + parseInt(rawHex.slice(4, 6), 16) * t);
return (
<div
key={t}
style={{
width: 100,
height: 60,
borderRadius: 12,
backgroundColor: `rgb(${r}, ${g}, ${b})`,
}}
/>
);
})}
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Gradient Backgrounds and Color Effects
Linear Gradients in Satori
Satori supports linear and radial gradients via the backgroundImage property with standard CSS gradient syntax:
// A gradient OG image for an article
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundImage: 'linear-gradient(135deg, #FF5733 0%, #FF8C69 50%, #FFC4A3 100%)',
alignItems: 'center',
justifyContent: 'center',
padding: 80,
}}
>
<h1 style={{ color: '#FFFFFF', fontSize: 64, fontWeight: 800, textAlign: 'center' }}>
{title}
</h1>
</div>
For generating the gradient stops programmatically from a base color, use the hue and lightness values to create harmonious transitions:
function makeGradientStops(hex: string): string {
const [h, s, l] = hexToHsl(hex);
const light = `hsl(${h}, ${s}%, ${Math.min(l + 20, 85)}%)`;
const dark = `hsl(${h}, ${Math.min(s + 10, 100)}%, ${Math.max(l - 20, 15)}%)`;
return `linear-gradient(135deg, ${dark} 0%, ${hex} 50%, ${light} 100%)`;
}
Use the Gradient Generator to preview color stop combinations before implementing them in OG templates.
Radial Gradient for a Spotlight Effect
A radial gradient centered slightly off-center creates a more dynamic, editorial feel:
<div
style={{
backgroundImage: `radial-gradient(ellipse at 30% 50%, ${lightVariant} 0%, ${hex} 40%, ${darkVariant} 100%)`,
}}
/>
Layered Color Panels
For articles about color palettes or multi-color topics, divide the image into color strips:
function ColorStripsTemplate({ colors }: { colors: string[] }) {
return (
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
{colors.map((color, i) => (
<div
key={i}
style={{
flex: 1,
backgroundColor: color,
display: 'flex',
alignItems: 'flex-end',
padding: '20px 10px',
}}
>
<span
style={{
color: bestTextColor(color),
fontSize: 14,
fontFamily: 'monospace',
opacity: 0.8,
}}
>
{color}
</span>
</div>
))}
</div>
);
}
Caching Strategies for Generated Images
The Problem
Without caching, every OG image request triggers full computation: parsing the query string, running color math, and converting SVG to PNG via WebAssembly. For a high-traffic site, this adds up.
With the Edge Runtime, @vercel/og runs at CDN edge nodes. The response itself is cacheable at the CDN layer â the key is setting the right Cache-Control headers.
Cache-Control Headers
export async function GET(request: NextRequest) {
// ... generate the image ...
return new ImageResponse(jsx, {
width: 1200,
height: 630,
headers: {
// Cache at CDN for 24 hours, allow stale for 7 days while revalidating
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
}
For pages where the OG image is determined by immutable content (a specific hex color, a static article title), a much longer cache is appropriate:
Cache-Control: public, max-age=2592000, immutable
// 30 days, no revalidation
Content-Addressed Cache Keys
Structure your OG image URLs so the URL itself is the cache key â and so the URL changes whenever the content changes:
/og?hex=FF5733&v=1 // Version parameter forces cache busting
/og?hex=FF5733&t=1708000 // Timestamp forces regeneration on deploy
In Next.js, you can include the deployment ID in the OG image URL to ensure all cached images are invalidated after each deploy:
// In your page component's generateMetadata
const deployId = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA?.slice(0, 8) ?? '0';
export async function generateMetadata({ params }: PageProps) {
const hex = params.hex;
return {
openGraph: {
images: [`/og?hex=${hex}&v=${deployId}`],
},
};
}
Custom Font Loading
Satori uses system fonts by default, which vary by platform and may not match your brand. To embed a specific font:
// Fetch font once and pass to ImageResponse
const fontData = await fetch(
new URL('../../assets/fonts/Inter-Bold.ttf', import.meta.url)
).then(r => r.arrayBuffer());
return new ImageResponse(jsx, {
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
weight: 700,
style: 'normal',
},
],
});
Cache the font fetch â loading a font binary on every request is wasteful. In an Edge Function, fonts can be imported as static assets and embedded at build time, making the load cost zero at runtime:
// Using Next.js static asset import (Edge-compatible)
import fontBuffer from '../../assets/fonts/Inter-Bold.ttf';
// fontBuffer is an ArrayBuffer available at zero cost after the first warm request
Registering OG Images in page Metadata
The final step is wiring the generated image into each page's metadata. In App Router:
// app/color/[hex]/page.tsx
export async function generateMetadata({ params }: { params: { hex: string } }) {
const hex = params.hex.toUpperCase();
const ogUrl = `/og?hex=${hex}`;
return {
openGraph: {
title: `#${hex} â Color Details`,
description: `Explore hex color #${hex}: RGB values, HSL breakdown, contrast ratios, and color palettes.`,
images: [
{
url: ogUrl,
width: 1200,
height: 630,
alt: `Color swatch for #${hex}`,
},
],
},
twitter: {
card: 'summary_large_image',
images: [ogUrl],
},
};
}
For the Color Converter and other tool pages where the URL does not carry content state, use a static pre-generated OG image with the tool's screenshot or illustration â dynamic generation only makes sense when the page content is itself dynamic.
Debugging and Testing OG Images
Local Testing
Test your route handler directly in the browser:
http://localhost:3000/og?hex=FF5733&name=Coral+Red
The browser renders the PNG directly. Check that gradients render correctly, text is legible, and font loading works.
Social Media Debugging Tools
Before deploying, verify the OG meta tags will be parsed correctly:
- Facebook Sharing Debugger:
developers.facebook.com/tools/debug/ - Twitter Card Validator:
cards-dev.twitter.com/validator - LinkedIn Post Inspector:
linkedin.com/post-inspector/ - OpenGraph.xyz: Free online OG meta tag preview
After deployment, paste your URL into each tool. They fetch the page, parse OG meta tags, and show you exactly how the card will look.
Common Issues
SVG text layout differences: Satori's text layout occasionally differs from browser rendering, especially with long strings or custom fonts. Always test with real content at the character lengths you expect.
Missing @vercel/og WebAssembly files: When running locally outside Next.js (e.g., in a test), the resvg WASM binary may not load. This is a development-only issue; in Next.js's Edge Runtime it is bundled automatically.
Gradient color accuracy: The hex colors in CSS gradient strings must be in 6-digit form â #FF5733, not #F53. Always normalize with .replace('#', '').padStart(6, '0') before interpolating into gradient strings.
Key Takeaways
@vercel/ogwith Satori converts JSX to PNG at the Edge, with no browser, no Puppeteer, and no cold-start latency.- Compute text color from background using relative luminance â never hardcode white text on an unknown background.
- Programmatically generate gradient stops from a base color's HSL values for consistent, harmonious OG image backgrounds.
- Use the
Cache-Control: public, max-age=86400, stale-while-revalidatepattern for color-parameterized OG images; useimmutablefor static content. - Load fonts as static assets bundled at build time â never fetch a font binary on every request.
- Wire OG image URLs into
generateMetadatafor App Router, ensuring each color page gets a custom image with the correct hex as the background. - Use the Gradient Generator to preview gradient stop combinations, and the Color Converter to translate any hex into the exact CSS values used in OG templates.