← Back to Blog

Next.js Dynamic OG Images: generateMetadata, @vercel/og, and OGPix

A deep dive into every approach for dynamic OG images in Next.js App Router — from the built-in metadata API to self-hosted Satori to using an external API like OGPix.

The Next.js App Router Metadata API

Next.js 13+ introduced the App Router with a powerful metadata system. You can define OG images using either a static metadata export or a dynamic generateMetadata function. The latter is what you need for pages with dynamic content like blog posts, product pages, or user profiles.

Static Metadata Export

For pages with fixed content, export a metadata object directly:

// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About Us",
  description: "Learn about our company",
  openGraph: {
    title: "About Us",
    description: "Learn about our company",
    images: [
      {
        url: "https://ogpix-pi.vercel.app/api/og?title=About+Us&theme=dark&key=YOUR_KEY",
        width: 1200,
        height: 630,
      },
    ],
  },
};

This works great for landing pages, about pages, and other static routes. But for dynamic routes, you need generateMetadata.

Dynamic Metadata with generateMetadata

The generateMetadata function runs at request time (or build time with generateStaticParams) and can access route parameters, fetch data, and construct dynamic OG image URLs:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  // Build the OGPix URL with dynamic data
  const ogUrl = new URL("https://ogpix-pi.vercel.app/api/og");
  ogUrl.searchParams.set("title", post.title);
  ogUrl.searchParams.set("description", post.excerpt);
  ogUrl.searchParams.set("theme", "dark");
  ogUrl.searchParams.set("key", process.env.OGPIX_KEY!);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
      images: [
        {
          url: ogUrl.toString(),
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [ogUrl.toString()],
    },
  };
}

This pattern gives every blog post a unique OG image without any manual design work. The image is generated on-demand when a social platform scrapes the URL.

Key generateMetadata Features

  • Automatic deduplication: Next.js deduplicates fetch calls, so if both generateMetadata and your page component call getPost(slug), the data is fetched only once.
  • Streaming compatible: Metadata is resolved before streaming the page content, so social crawlers always get the meta tags even with streaming enabled.
  • Parent metadata merging: Child layouts can extend parent metadata using the parent parameter, allowing you to set defaults at the layout level.
  • Type safe: The Metadata type from next provides full TypeScript support for all possible meta tag fields.

Self-Hosted Approach: @vercel/og

If you prefer to generate OG images within your own Next.js app, you can use @vercel/og (which uses Satori under the hood). This creates a Route Handler that returns an ImageResponse:

// app/api/og/route.tsx
import { ImageResponse } from "@vercel/og";

export const runtime = "edge";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") || "My App";
  const description = searchParams.get("description") || "";

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          background: "linear-gradient(135deg, #0f172a, #1e293b)",
          padding: 60,
        }}
      >
        <h1 style={{ fontSize: 64, color: "white", textAlign: "center" }}>
          {title}
        </h1>
        {description && (
          <p style={{ fontSize: 28, color: "#94a3b8", textAlign: "center" }}>
            {description}
          </p>
        )}
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

OGPix API vs Self-Hosted @vercel/og: Comparison

FeatureOGPix APISelf-Hosted @vercel/og
Setup time2 minutes (get API key)30-60 minutes
Custom templatesPre-built themesFull control (JSX)
Custom fontsHandled automaticallyManual font loading
CachingCDN includedYou manage caching
Rate limitingBuilt-inYou implement
AnalyticsDashboard includedNot available
Cold startsNone (CDN)Possible on edge
Hosting costFree tier availablePart of your infra

The self-hosted approach gives you maximum design flexibility — you write the JSX template, load custom fonts, and control every pixel. The tradeoff is that you're responsible for caching, monitoring, and handling edge cases like timeout errors from social crawlers.

OGPix handles all that infrastructure for you. If your design needs are covered by the available themes, it's the faster path to production-ready OG images.

Using the opengraph-image Convention

Next.js also supports a file-based convention for OG images. Create an opengraph-image.tsx file in any route directory:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const alt = "Blog post image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);

  return new ImageResponse(
    (
      <div style={{ /* your styles */ }}>
        <h1>{post.title}</h1>
      </div>
    ),
    { ...size }
  );
}

This convention automatically adds the correct og:image meta tags — you don't need to manually set them in generateMetadata. However, you still need Satori-compatible JSX (no CSS Grid, limited flexbox) and must handle font loading yourself.

Best Practices for Next.js OG Images

  • Always set width and height: Include width: 1200 and height: 630 in the images array so platforms render previews faster.
  • Use absolute URLs: OG image URLs must be absolute. In Next.js, set metadataBase in your root layout to avoid issues.
  • Add Twitter card tags: Always include twitter.card: "summary_large_image" alongside your OG tags for the best Twitter/X display.
  • Set metadataBase: Define metadataBase: new URL("https://yourdomain.com") in your root layout to resolve relative OG image paths.
  • Keep titles short: OG image text should be under 60 characters for readability at small sizes.
  • Test before deploying: Use the Facebook Sharing Debugger, Twitter Card Validator, and LinkedIn Post Inspector to verify your meta tags render correctly.

Complete Example: Blog with OGPix

Here's a full example showing a Next.js blog with dynamic OG images via OGPix, including generateStaticParams for static generation:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost, getAllSlugs } from "@/lib/blog";

type Props = { params: Promise<{ slug: string }> };

export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  const ogUrl = new URL("https://ogpix-pi.vercel.app/api/og");
  ogUrl.searchParams.set("title", post.title);
  ogUrl.searchParams.set("description", post.excerpt);
  ogUrl.searchParams.set("theme", "dark");
  ogUrl.searchParams.set("key", process.env.OGPIX_KEY!);

  return {
    title: `${post.title} — My Blog`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
      images: [{ url: ogUrl.toString(), width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      images: [ogUrl.toString()],
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Add OG images to your Next.js app in 2 minutes

Get an API key, drop the URL into your generateMetadata, and every page gets a unique social preview. No packages to install.

Get Free API Key →