27 JUN 2026Next.js · Frontend Development · SEO

Making your own dynamic OG images in Next.js

Every link you share has a preview. Here is how I made mine actually say something.

Every time you share a link, something happens before anyone even clicks it. Twitter, LinkedIn, WhatsApp, they all scrape your page and pull an image to show as a preview. That image is your OG image.

Most people either ignore it (grey box, no image) or set one static image for the whole site and call it done. That works. But it means every page on your site looks the same when shared.

Dynamic OG images let each page generate its own image, with its own title, description, tags, whatever you want. And in Next.js App Router, it is genuinely simple.

How Next.js handles it

Next.js has a file convention for this. Drop an opengraph-image.tsx file into any route segment and it gets treated as an image endpoint automatically. No API route needed, no manual <meta> tags. Next.js wires everything up.

// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
 
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default function Image() {
  return new ImageResponse(<YourComponent />, size);
}

ImageResponse from next/og takes a React element and renders it to a PNG using Satori under the hood. The catch is that it only supports inline styles. No Tailwind, no CSS files. Everything goes in style={{}}.

The background that ties it all together

Before getting into the cards themselves, both the home and feed OG images share a BlockBackground component. It draws a subtle grid with randomly placed dark tiles on top, same vibe as the portfolio site itself.

The grid is 18 columns by 9 rows of 70px cells. Vertical and horizontal lines are drawn as 1px dividers. Then 24 random tiles get placed on top using a seeded random function so the layout is deterministic (same image every render, no layout shift between builds).

const tileColors = ["#0b0b0d", "#0c0c0e", "#0d0d0f", "#0e0e10", "#101012"];
 
function seededRandom(seed: number) {
  let value = seed;
  return () => {
    value = (value * 1664525 + 1013904223) % 4294967296;
    return value / 4294967296;
  };
}
 
const blockTiles = (() => {
  const random = seededRandom(20260229);
  const cells: { x: number; y: number }[] = [];
 
  for (let row = 0; row < 9; row += 1) {
    for (let col = 0; col < 18; col += 1) {
      cells.push({ x: col * 70, y: row * 70 });
    }
  }
 
  for (let index = cells.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(random() * (index + 1));
    const current = cells[index];
    const swap = cells[swapIndex];
    if (!current || !swap) continue;
    cells[index] = swap;
    cells[swapIndex] = current;
  }
 
  return cells.slice(0, 24).map((cell) => ({
    ...cell,
    color: tileColors[Math.floor(random() * tileColors.length)] ?? "#0d0d0f",
  }));
})();

The BlockBackground component renders those grid lines and tiles as absolute-positioned divs, then overlays a subtle gradient to fade the edges:

function BlockBackground() {
  const verticalLines = Array.from({ length: 18 }, (_, i) => i * 70);
  const horizontalLines = Array.from({ length: 10 }, (_, i) => i * 70);
 
  return (
    <div
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        width: 1200,
        height: 630,
        display: "flex",
        overflow: "hidden",
        background: "#09090b",
      }}
    >
      {verticalLines.map((left) => (
        <div
          key={`v-${left}`}
          style={{
            position: "absolute",
            top: 0,
            left,
            width: 1,
            height: "100%",
            display: "flex",
            background: "#101012",
          }}
        />
      ))}
      {horizontalLines.map((top) => (
        <div
          key={`h-${top}`}
          style={{
            position: "absolute",
            top,
            left: 0,
            width: "100%",
            height: 1,
            display: "flex",
            background: "#0f0f11",
          }}
        />
      ))}
      {blockTiles.map((tile, index) => (
        <div
          key={index}
          style={{
            position: "absolute",
            left: tile.x,
            top: tile.y,
            width: 70,
            height: 70,
            display: "flex",
            background: tile.color,
          }}
        />
      ))}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: 1200,
          height: 630,
          display: "flex",
          background:
            "linear-gradient(90deg, rgba(9,9,11,0.08), rgba(9,9,11,0.02) 44%, rgba(9,9,11,0.2)), linear-gradient(180deg, rgba(9,9,11,0.08), rgba(9,9,11,0.22))",
        }}
      />
    </div>
  );
}

The seeded random is key here. Without it Satori would generate a different tile layout on every build and your cached OG images would invalidate for no reason. Same seed, same layout, every time.

The static one (home page)

For my portfolio home page, the OG image mirrors the site itself. Dark background with the BlockBackground grid, the accent green (#c7ff30), same typographic feel, and the CTA buttons from the actual landing page.

export function HomeOgCard() {
  return (
    <div
      style={{
        position: "relative",
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        overflow: "hidden",
        background: "#08080a",
        color: "#f4f4f5",
        padding: "58px 68px",
      }}
    >
      <BlockBackground />
 
      <div
        style={{
          position: "relative",
          display: "flex",
          color: "#a1a1aa",
          fontSize: 20,
          letterSpacing: "0.04em",
        }}
      >
        dalgoridim<span style={{ color: "#c7ff30" }}>.</span>
      </div>
 
      <div
        style={{
          position: "relative",
          display: "flex",
          flexDirection: "column",
          maxWidth: 1040,
          fontSize: 76,
          lineHeight: 0.98,
          letterSpacing: "-0.045em",
          fontWeight: 600,
        }}
      >
        <div style={{ display: "flex" }}>Daniel builds</div>
        <div style={{ display: "flex" }}>
          accessible, fast&nbsp;
          <span style={{ color: "#8f8f98" }}>web</span>
        </div>
        <div style={{ display: "flex", color: "#8f8f98" }}>
          experiences people
        </div>
        <div style={{ display: "flex", color: "#8f8f98" }}>actually enjoy.</div>
      </div>
 
      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        }}
      >
        <div style={{ display: "flex", gap: 16 }}>
          <div
            style={{
              display: "flex",
              alignItems: "center",
              borderRadius: 999,
              background: "#c7ff30",
              color: "#101012",
              padding: "13px 24px",
              fontSize: 18,
              fontWeight: 600,
            }}
          >
            Let's talk ↗
          </div>
          <div
            style={{
              display: "flex",
              alignItems: "center",
              border: "1px solid #2d2d32",
              borderRadius: 999,
              background: "#141416",
              padding: "13px 24px",
              fontSize: 18,
            }}
          >
            View work
          </div>
        </div>
        <div style={{ display: "flex", color: "#71717a", fontSize: 18 }}>
          Frontend developer
        </div>
      </div>
    </div>
  );
}

The CTA buttons aren't clickable obviously (it's a PNG), but they make the preview feel like a screenshot of the actual site rather than a generic card. That's the point.

// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { HomeOgCard } from "@/components/og-card";
 
export const alt = "Daniel builds accessible, fast web experiences people actually enjoy.";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default function Image() {
  return new ImageResponse(<HomeOgCard />, size);
}

OG image preview

The dynamic one (feed posts)

This is where it gets interesting. For each post in my feed, the OG image shows the post's actual title, description, and tags. Not the same generic card every time.

The OgCard component takes props and layers everything over the same BlockBackground. All content elements get position: "relative" so they sit above the absolute-positioned background:

export function OgCard({
  eyebrow,
  title,
  description,
  tags = [],
}: {
  eyebrow: string;
  title: string;
  description: string;
  tags?: string[];
}) {
  return (
    <div
      style={{
        position: "relative",
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        overflow: "hidden",
        background: "#09090b",
        color: "#f4f4f5",
        padding: "68px 76px",
        border: "1px solid #27272a",
      }}
    >
      <BlockBackground />
 
      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "center",
          gap: 18,
        }}
      >
        <div
          style={{ width: 54, height: 4, display: "flex", background: "#c7ff5e" }}
        />
        <div
          style={{
            display: "flex",
            color: "#c7ff5e",
            fontSize: 23,
            letterSpacing: "0.18em",
            textTransform: "uppercase",
          }}
        >
          {eyebrow}
        </div>
      </div>
 
      <div
        style={{
          position: "relative",
          display: "flex",
          flexDirection: "column",
          gap: 24,
        }}
      >
        <div
          style={{
            display: "flex",
            maxWidth: 1040,
            fontSize: title.length > 55 ? 60 : 72,
            lineHeight: 1.02,
            letterSpacing: "-0.04em",
            fontWeight: 700,
          }}
        >
          {title}
        </div>
        <div
          style={{
            display: "flex",
            maxWidth: 940,
            color: "#a1a1aa",
            fontSize: 27,
            lineHeight: 1.35,
          }}
        >
          {description}
        </div>
      </div>
 
      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          color: "#71717a",
          fontSize: 20,
        }}
      >
        <div style={{ display: "flex", gap: 12 }}>
          {tags.slice(0, 4).map((tag) => (
            <div
              key={tag}
              style={{
                display: "flex",
                border: "1px solid #3f3f46",
                borderRadius: 999,
                padding: "8px 15px",
                color: "#d4d4d8",
              }}
            >
              {tag}
            </div>
          ))}
        </div>
        <div style={{ display: "flex" }}>
          {process.env.NEXT_PUBLIC_SITE_URL?.replace("https://", "") ??
            "dalgoridim.com"}
        </div>
      </div>
    </div>
  );
}

Then the image file for the feed route fetches the post by slug and passes the data in:

// app/f/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { OgCard } from "@/components/og-card";
import { getFeedPost } from "@/lib/cms/feed";
import { summarize } from "@/lib/seo";
 
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;
  const post = await getFeedPost(slug);
 
  return new ImageResponse(
    <OgCard
      eyebrow="The feed"
      title={post?.title ?? "Daniel Fadamitan's feed"}
      description={summarize(
        post?.excerpt ?? "Frontend engineering notes, lessons, and observations.",
        150,
      )}
      tags={post?.tags}
    />,
    size,
  );
}

A few things worth noting here:

The function is async. You can fetch data directly inside an opengraph-image.tsx. It runs on the server, same as a Server Component. So fetching by slug and pulling the real post title is straightforward.

summarize strips markdown. OG descriptions should be plain text. I have a small utility that removes markdown syntax and trims to a character limit before passing it to the card.

Font size adjusts to title length. Long titles drop from 72px to 60px automatically so nothing clips. Small thing, makes a difference.

Feed post OG image

Wiring up the metadata

The image file handles generation, but you still need to tell Next.js to reference it in your <head>. I have a createPageMetadata helper that builds consistent metadata for every page:

// lib/seo.ts
export function createPageMetadata({
  title,
  description,
  path,
  image = "/opengraph-image",
}: {
  title: string;
  description: string;
  path: string;
  image?: string;
}): Metadata {
  const canonical = absoluteUrl(path);
  const imageUrl = absoluteUrl(image);
 
  return {
    title,
    description,
    alternates: { canonical },
    openGraph: {
      url: canonical,
      title: `${title} | Daniel Fadamitan`,
      description,
      images: [{ url: imageUrl, width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      creator: "@D_Invalid1",
      images: [imageUrl],
    },
  };
}

For dynamic routes the image path points to the route-level OG endpoint. For static pages it falls back to the home one.

One thing that will catch you

Satori (what ImageResponse uses under the hood) does not support all CSS. Specifically:

  • Every element needs display: "flex" explicitly. Block layout does not work.
  • No CSS Grid.
  • No gap shorthand on some older versions. Use rowGap / columnGap if things look off.
  • Custom fonts need to be loaded manually via the second argument of ImageResponse.

If something is not rendering the way you expect, display: "flex" on the element is usually the first thing to check.

That is basically all of it. One shared background component, one card component per variant, one image file per route, async data fetching where needed. Every shared link gets its own card.