blog
·5 min read

Building Custom OG Images with Next.js

When you share a link on Twitter, LinkedIn, Slack, the preview card that appears is powered by Open Graph (OG) meta tags. The og:image tag controls what image shows up in that card. A well-designed OG image makes your content stand out in feeds and drives more clicks.

Most sites use static images or generic screenshots for this. But with Next.js, you can generate OG images dynamically — rendering JSX to PNG on the server, driven by the same data that powers your pages.

How Next.js Generates Images

Next.js ships with ImageResponse from the next/og module. Under the hood, it uses Satori to convert a JSX tree into an SVG, then renders that SVG to a PNG.

The flow looks like this:

  1. A social platform (or browser) requests your OG image URL
  2. Your Next.js route handler runs on the server
  3. ImageResponse takes your JSX, converts it to SVG via Satori, then to PNG
  4. The PNG is returned as the HTTP response

Because the image is generated at request time, you can pull in dynamic data — article titles, author names, dates — and render them directly into the image.

Setting Up the Route Handler

Create a route handler at app/api/og/route.tsx:

tsx
import { ImageResponse } from "next/og";
import { type NextRequest } from "next/server";
 
export async function GET(request: NextRequest) {
  const title = request.nextUrl.searchParams.get("title") ?? "My Site";
 
  return new ImageResponse(
    (
      <div
        style={{
          background: "#0a0a0a",
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: "60px",
        }}
      >
        <h1 style={{ color: "white", fontSize: "48px" }}>{title}</h1>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    },
  );
}

That's the minimal version. Hit /api/og?title=Hello%20World and you get a 1200x630 PNG with white text on a dark background.

A few things to note:

  • The standard OG image size is 1200x630 pixels
  • The JSX inside ImageResponse is not React — it's a subset that Satori understands
  • You must use inline styles, not Tailwind classes or CSS modules

Styling the Image

Satori supports a subset of CSS, primarily flexbox. No CSS Grid, no position: absolute relative to arbitrary ancestors, no pseudo-elements. Think of it as styling an email — keep it simple.

Here's a more polished layout:

tsx
return new ImageResponse(
  (
    <div
      style={{
        background: "#121927",
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        padding: "80px 60px",
      }}
    >
      <h1
        style={{
          fontSize: "52px",
          fontWeight: "400",
          color: "#f5f5f4",
          lineHeight: "1.2",
          maxWidth: "900px",
          letterSpacing: "-0.02em",
        }}
      >
        {title}
      </h1>
 
      <div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
        <div
          style={{
            width: "48px",
            height: "3px",
            background: "linear-gradient(90deg, #d97706, #f59e0b)",
            borderRadius: "2px",
          }}
        />
        <span style={{ fontSize: "18px", color: "#a8a29e" }}>
          mysite.com
        </span>
      </div>
    </div>
  ),
  { width: 1200, height: 630 },
);

This creates a dark card with the title at the top and a subtle branded footer — an amber gradient accent line next to the domain name. Simple and clean.

Supported CSS properties include: display: flex, flexDirection, justifyContent, alignItems, gap, padding, margin, background, color, fontSize, fontWeight, lineHeight, letterSpacing, borderRadius, border, maxWidth, opacity, and background with linear-gradient. Check the Satori docs for the full list.

Using Custom Fonts

By default, Satori uses a system font. To use a custom font, load the font file and pass it to ImageResponse:

tsx
import fs from "fs";
import path from "path";
 
export async function GET(request: NextRequest) {
  const fontData = fs.readFileSync(
    path.join(process.cwd(), "public/fonts/MyFont-Regular.ttf")
  );
 
  return new ImageResponse(
    (
      <div style={{ fontFamily: "My Font", /* ... */ }}>
        {/* your layout */}
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "My Font",
          data: fontData,
          style: "normal",
          weight: 400,
        },
      ],
    },
  );
}

The name in the fonts array must match the fontFamily in your styles. You can load multiple weights or families by adding more entries to the array.

Tip: Use .ttf files. Satori has the best support for TrueType fonts. Variable fonts (.woff2) may not work as expected.

Wiring It Up with Metadata

The image route is useless unless your pages actually reference it. In Next.js App Router, use generateMetadata to set the og:image tag dynamically:

tsx
// app/blog/[slug]/page.tsx
 
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug);
 
  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: {
        url: `/api/og?title=${encodeURIComponent(post.title)}`,
        width: 1200,
        height: 630,
        type: "image/png",
      },
    },
  };
}

When a social platform crawls your page, it reads the og:image meta tag, fetches that URL, and displays the generated PNG in the link preview.

For production, use an absolute URL (e.g., https://mysite.com/api/og?title=...). You can set metadataBase in your root layout to handle this automatically:

tsx
// app/layout.tsx
 
export const metadata: Metadata = {
  metadataBase: new URL("https://mysite.com"),
};

Tips and Gotchas

CSS limitations are real. No grid, no position: absolute in most cases, no transform, no box-shadow. Design your layout with flexbox from the start. If something doesn't render, check the Satori supported CSS list.

Images inside OG images are tricky. If you want to embed an image (like an avatar), you need to pass the full absolute URL or base64-encode it. Relative paths won't work since the rendering happens in a server context.

Caching matters. On Vercel, you can add cache headers to avoid regenerating the same image on every request:

tsx
export async function GET(request: NextRequest) {
  // ... generate image
  const response = new ImageResponse(/* ... */);
  response.headers.set("Cache-Control", "public, max-age=86400");
  return response;
}

Size limits. Vercel's Edge Functions have a response size limit. Keep your images simple — avoid embedding large photos or complex graphics. Text-based designs work best.

Test with real platforms. Use tools like opengraph.xyz to preview how your OG images look when shared. Each platform crops and displays them slightly differently.

Debug locally. During development, just visit your OG route directly in the browser (e.g., localhost:3000/api/og?title=Test) to see the generated image without needing to share a link.