Tech Verse Logo
Enable dark mode
Generating Open Graph Images at the Edge in Next.js 16

Generating Open Graph Images at the Edge in Next.js 16

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

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 width and height percentages 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.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js 16 Caching Architecture: The Four Layers

    Next.js 16 Caching Architecture: The Four Layers

    Dynamic Imports and Code Splitting in Next.js 16

    Dynamic Imports and Code Splitting in Next.js 16

    Next.js Image Optimization: Sizes and Remote Patterns

    Next.js Image Optimization: Sizes and Remote Patterns