ProgrUmar Logo
Module 4: Advanced SEO & Metadata

Open Graph & Twitter Card Images

Duration: 14 mins

Dynamic OG Images

Create an opengraph-image.tsx file in any route segment. Next.js renders it with ImageResponse on the edge — you can use JSX, custom fonts, and even fetch data to make each image unique.

Open Graph & Twitter Card Images

When someone shares your blog post on Twitter, LinkedIn, or Slack, the first thing people see is the preview image. A bland, generic image gets ignored. A sharp, on-brand image with the post title and author gets clicked. The difference in click-through rate can be dramatic — and it costs you nothing extra if you generate these images automatically.

Next.js has a built-in system for generating dynamic Open Graph images using JSX and CSS — no Puppeteer, no headless Chrome, no external service required. The images are generated at the edge, cached automatically, and each one is unique to its page. This lesson covers everything from basic static OG images to fully dynamic, data-driven image generation.


1. The Two Approaches to OG Images

Next.js gives you two ways to handle OG images:

Approach How it works Best for
Static file Place opengraph-image.png in a route folder Pages that always show the same image
Dynamic generation Create opengraph-image.tsx that returns an ImageResponse Pages where the image should reflect the page content

For most real projects you'll use both — a static default OG image at the root level and dynamic images for blog posts, product pages, and user profiles.


2. Static OG Images — The Simple Case

Place an image file named opengraph-image (with any image extension) in any route folder. Next.js automatically adds the correct <meta property="og:image"> tag for that route:

app/
├── opengraph-image.png       ← Default OG image for the whole site
├── page.tsx
└── blog/
    ├── opengraph-image.png   ← OG image specifically for /blog
    └── page.tsx

Supported filenames and their purposes:

File Meta tag generated
opengraph-image.png og:image
twitter-image.png twitter:image
opengraph-image.jpg og:image
opengraph-image.gif og:image

Design your static OG images at exactly 1200×630 pixels. Export them as PNG or JPG (PNG for images with text, JPG for photographs). Keep file size under 1MB — most platforms have size limits for OG image previews.


3. Dynamic OG Images with ImageResponse

Create a file named opengraph-image.tsx (note the .tsx extension — this is a JavaScript file, not an image file). Export a default function that returns an ImageResponse:

// app/opengraph-image.tsx — default site-wide OG image
import { ImageResponse } from "next/og";

// Tell Next.js to generate this as a static image at build time
export const runtime = "edge";

export const alt = "Acme Corp";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default function OgImage() {
  return new ImageResponse(
    (
      <div
        style={{
          background: "linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%)",
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          fontFamily: "sans-serif",
        }}
      >
        <div
          style={{
            fontSize: 72,
            fontWeight: 800,
            color: "white",
            letterSpacing: "-2px",
          }}
        >
          Acme Corp
        </div>
        <div
          style={{
            fontSize: 28,
            color: "rgba(255, 255, 255, 0.8)",
            marginTop: 16,
          }}
        >
          Build better software, faster
        </div>
      </div>
    ),
    { ...size }
  );
}

Next.js renders this JSX to a PNG image using the @vercel/og package (bundled with Next.js). The JSX is limited to a subset of CSS — we'll cover the constraints in detail below.


4. Dynamic OG Images for Blog Posts

The real power is per-page dynamic images. Add an opengraph-image.tsx inside a dynamic route folder — it receives the same params as the page and can fetch data to generate a unique image for each post:

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

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

export default async function OgImage({
  params,
}: {
  params: { slug: string };
}) {
  // Fetch the post data
  const post = await db.post.findUnique({
    where: { slug: params.slug },
    select: {
      title: true,
      excerpt: true,
      author: { select: { name: true, avatar: true } },
      category: { select: { name: true } },
    },
  });

  if (!post) {
    // Return a fallback image if the post doesn't exist
    return new ImageResponse(
      <div style={{ background: "#1e3a8a", width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
        <div style={{ color: "white", fontSize: 48 }}>Post Not Found</div>
      </div>,
      { ...size }
    );
  }

  return new ImageResponse(
    (
      <div
        style={{
          background: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          padding: "60px 80px",
          fontFamily: "sans-serif",
        }}
      >
        {/* Top accent bar */}
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            height: 8,
            background: "linear-gradient(90deg, #2563eb, #7c3aed)",
          }}
        />

        {/* Category tag */}
        <div
          style={{
            display: "flex",
            alignItems: "center",
            marginTop: 20,
          }}
        >
          <div
            style={{
              background: "#dbeafe",
              color: "#1d4ed8",
              fontSize: 18,
              fontWeight: 600,
              padding: "6px 16px",
              borderRadius: 999,
            }}
          >
            {post.category?.name ?? "Article"}
          </div>
        </div>

        {/* Post title */}
        <div
          style={{
            fontSize: post.title.length > 60 ? 48 : 60,
            fontWeight: 800,
            color: "#111827",
            lineHeight: 1.15,
            marginTop: 32,
            flex: 1,
          }}
        >
          {post.title}
        </div>

        {/* Author row */}
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 16,
            marginTop: 40,
          }}
        >
          {/* Author avatar */}
          {post.author.avatar && (
            <img
              src={post.author.avatar}
              width={52}
              height={52}
              style={{ borderRadius: "50%", border: "3px solid #e5e7eb" }}
            />
          )}

          {/* Author info */}
          <div style={{ display: "flex", flexDirection: "column" }}>
            <div style={{ fontSize: 20, fontWeight: 600, color: "#374151" }}>
              {post.author.name}
            </div>
            <div style={{ fontSize: 16, color: "#9ca3af" }}>
              acmecorp.com
            </div>
          </div>

          {/* Site logo — right side */}
          <div
            style={{
              marginLeft: "auto",
              fontSize: 28,
              fontWeight: 800,
              color: "#2563eb",
              letterSpacing: "-1px",
            }}
          >
            Acme Corp
          </div>
        </div>
      </div>
    ),
    { ...size }
  );
}

Every blog post now gets its own unique OG image showing the post title, category, author name, and avatar — automatically, without any manual image creation.


5. Loading Custom Fonts in OG Images

The default ImageResponse uses system fonts. For brand-consistent OG images, load your custom font — Google Fonts work well since they're publicly accessible:

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

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

// Load the font once outside the function — cached across requests
async function loadFont(text: string) {
  const url = "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff";
  const response = await fetch(url);
  return response.arrayBuffer();
}

export default async function OgImage({
  params,
}: {
  params: { slug: string };
}) {
  const [post, interFont] = await Promise.all([
    getPostBySlug(params.slug),
    loadFont(""),
  ]);

  return new ImageResponse(
    (
      <div
        style={{
          fontFamily: "'Inter'",
          // ... rest of your JSX
        }}
      >
        <div style={{ fontWeight: 800 }}>{post?.title}</div>
      </div>
    ),
    {
      ...size,
      fonts: [
        {
          name: "Inter",
          data: interFont,
          style: "normal",
          weight: 800,
        },
      ],
    }
  );
}

Alternatively, use a local font file from your project. Place it in the public/fonts/ folder and fetch it with a relative URL:

// Fetch a local font file
const fontData = await fetch(
  new URL("../../../public/fonts/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());

6. CSS Constraints in ImageResponse

ImageResponse doesn't support the full CSS specification — it uses a subset powered by the satori library. Knowing what's supported prevents hours of debugging:

Supported CSS

  • Flexbox layout (display: flex) — this is the primary layout system
  • Absolute and relative positioning
  • Background colours, gradients, and images
  • Border, border-radius, box-shadow
  • Font properties (size, weight, family, color, line-height, letter-spacing)
  • Width, height, padding, margin, gap
  • Opacity
  • Overflow: hidden
  • Object-fit for images

NOT Supported

  • display: grid — use nested flexbox instead
  • CSS animations and transitions
  • calc() expressions
  • CSS variables (var(--color))
  • Pseudo-elements (::before, ::after)
  • text-overflow: ellipsis — truncate text in JavaScript instead
  • Most Tailwind classes — use inline style objects only

The golden rule: use display: flex for everything and inline style objects instead of className. Tailwind classes are not supported inside ImageResponse.


7. Dynamic OG Images for Product Pages

Here's a product page OG image that pulls in the product image, name, price, and rating — making every product's social share look like a proper ad:

// app/product/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { db } from "@/lib/db";

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

export default async function OgImage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    select: { name: true, price: true, rating: true, image: true, category: true },
  });

  if (!product) return new Response("Not found", { status: 404 });

  const stars = "★".repeat(Math.round(product.rating)) + "☆".repeat(5 - Math.round(product.rating));

  return new ImageResponse(
    (
      <div
        style={{
          background: "#f9fafb",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          padding: "60px",
          gap: "60px",
          fontFamily: "sans-serif",
        }}
      >
        {/* Product image */}
        <div
          style={{
            width: 460,
            height: 460,
            borderRadius: 24,
            overflow: "hidden",
            background: "white",
            boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
            flexShrink: 0,
          }}
        >
          <img
            src={product.image}
            width={460}
            height={460}
            style={{ objectFit: "cover" }}
          />
        </div>

        {/* Product details */}
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            flex: 1,
            gap: 20,
          }}
        >
          <div style={{ fontSize: 20, color: "#6b7280", fontWeight: 500 }}>
            {product.category}
          </div>

          <div
            style={{
              fontSize: product.name.length > 40 ? 44 : 56,
              fontWeight: 800,
              color: "#111827",
              lineHeight: 1.1,
            }}
          >
            {product.name}
          </div>

          <div style={{ fontSize: 28, color: "#f59e0b", letterSpacing: 2 }}>
            {stars}
          </div>

          <div style={{ fontSize: 52, fontWeight: 800, color: "#2563eb" }}>
            ${product.price.toFixed(2)}
          </div>

          <div
            style={{
              background: "#2563eb",
              color: "white",
              fontSize: 22,
              fontWeight: 700,
              padding: "14px 32px",
              borderRadius: 12,
              alignSelf: "flex-start",
              marginTop: 8,
            }}
          >
            Shop Now →
          </div>
        </div>
      </div>
    ),
    { ...size }
  );
}

8. Twitter Card Images

Twitter (now X) uses its own image tag — twitter:image — and supports two card types:

  • summary — small square thumbnail (600×600px minimum)
  • summary_large_image — large banner image (1200×628px minimum) — this is what you want for most content

Create a separate twitter-image.tsx file if you want a different image for Twitter vs other platforms. Otherwise, Twitter falls back to the og:image — which is fine for most apps:

// app/blog/[slug]/twitter-image.tsx
// Only create this if you want a Twitter-specific image different from OG
import { ImageResponse } from "next/og";

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

export default async function TwitterImage({ params }: { params: { slug: string } }) {
  // Twitter prefers a slightly different aspect ratio and simpler design
  const post = await getPostBySlug(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          background: "#0f172a",
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          padding: "60px 80px",
          fontFamily: "sans-serif",
          justifyContent: "flex-end",
        }}
      >
        <div style={{ fontSize: 52, fontWeight: 800, color: "white", lineHeight: 1.2 }}>
          {post?.title}
        </div>
        <div style={{ fontSize: 22, color: "#94a3b8", marginTop: 20 }}>
          acmecorp.com · {post?.author.name}
        </div>
      </div>
    ),
    { ...size }
  );
}

9. Caching and Performance

OG images generated by ImageResponse are cached automatically:

  • Static routes — generated once at build time and served as static files. Zero runtime cost after the build.
  • Dynamic routes — generated on the first request, then cached by Next.js's Full Route Cache. Subsequent requests are served from cache.
  • Edge runtime — setting export const runtime = "edge" means the image is generated at the edge (Vercel's CDN nodes), not at a single origin server. This gives fast generation times regardless of where the user is.

To control cache duration, set the revalidate export:

// Cache for 1 hour, then regenerate on next request
export const revalidate = 3600;

// Never cache — regenerate on every request (useful during development)
export const revalidate = 0;

10. Testing Your OG Images

Several tools let you preview and debug OG images without deploying:

Locally

While running npm run dev, OG images are accessible at their file path. For app/blog/[slug]/opengraph-image.tsx, visit:

http://localhost:3000/blog/my-post-slug/opengraph-image

This renders and displays the image directly in the browser. Tweak the JSX and refresh to see changes instantly — much faster than deploying to test.

Social Preview Tools

  • Open Graph Debuggerdevelopers.facebook.com/tools/debug — tests Facebook/LinkedIn previews and clears their cache
  • Twitter Card Validatorcards-dev.twitter.com/validator — tests Twitter card appearance
  • opengraph.xyz — tests how your URL looks across multiple platforms at once
  • metatags.io — live preview of OG tags across social platforms

After deploying, always run your important pages through the Facebook debugger first — Facebook aggressively caches OG images and the debugger forces a refresh of their cache.


11. Common Gotchas

  • Using Tailwind classes inside ImageResponse. They don't work — Tailwind's stylesheet isn't loaded in the image generation context. Use inline style objects with camelCase CSS properties only.
  • Using CSS variables (var(--color)) in styles. CSS variables are not supported inside ImageResponse. Use literal colour values like "#2563eb" instead.
  • Trying to use display: grid. Only flexbox is supported. Recreate grid layouts using nested flex containers.
  • Images not showing in production but working locally. Make sure metadataBase is set in your root layout — without it, Next.js can't generate absolute URLs for the OG image, which social platforms require.
  • Font rendering looking wrong. The default system font varies by platform and may look different than expected. Load a specific font file for consistent rendering across all environments.
  • Title text overflowing the image. Long titles break the layout. Use conditional font sizing based on title length — reduce fontSize when title.length > 60 as shown in the blog post example above.
  • Fetching images from external URLs. External images must be accessible without authentication and served over HTTPS. Use absolute URLs for <img> tags inside ImageResponse.

Key Takeaways

  • Place opengraph-image.png in any route folder for a static OG image — Next.js handles the meta tag automatically.
  • Create opengraph-image.tsx returning an ImageResponse for dynamic, data-driven OG images.
  • The file receives the same params as its sibling page.tsx — use them to fetch post, product, or user data.
  • Always use inline style objects and display: flex — Tailwind classes and CSS grid are not supported.
  • Load a custom font via ArrayBuffer and the fonts option for consistent, brand-aligned typography.
  • OG images are cached automatically — static at build time, dynamic after first request.
  • Test locally by visiting /route/opengraph-image directly in the browser — no deployment needed.
  • Always set metadataBase in the root layout — social platforms require absolute URLs for OG images.

Next up: Lesson 403 — Sitemap & robots.txt Generation. You'll learn how to auto-generate XML sitemaps from your dynamic routes, split large sitemaps across multiple files, and configure robots.txt to control exactly which pages crawlers can access.

Chat with us