Tutorials

Generating Colorful OG Images with Next.js and Satori

10 min read

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/og with 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-revalidate pattern for color-parameterized OG images; use immutable for static content.
  • Load fonts as static assets bundled at build time — never fetch a font binary on every request.
  • Wire OG image URLs into generateMetadata for 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.

Related Colors

Related Brands

Related Tools