JUL 26, 2026
1350 words7 m

Dynamic OG Images in Next.js: The Errors Nobody Warns You About

Every blog post on this site now generates its own preview image. Share a link in Discord, X, or LinkedIn and you get a card with the actual post title and my avatar instead of the same generic image over and over.

It sounds trivial. It wasn't. I hit five different production errors before it worked. Here's the honest walkthrough.

Why bother with dynamic OG images

When you share a link anywhere, the platform scrapes the page for OpenGraph meta tags. If you don't have a unique image per post, every share looks identical. That's a missed opportunity.

Good OG images:

  • Boost click-through rates because the preview actually describes the content
  • Look professional in shares, chats, and DMs
  • Make your posts stand out from generic link previews
  • Are picked up by AI models that crawl your site

Bad OG images:

  • Show the same site logo for every post
  • Mismatch the actual content
  • Look like a Wordpress default

The setup that seems simple

Next.js App Router has a file convention: create opengraph-image.tsx in a route folder and it becomes the OG image for that route. Combined with @vercel/og for image rendering, you can build cards in JSX like a normal component.

The basic idea:

import { ImageResponse } from "next/og";
 
export const runtime = "nodejs";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default async function Image({ params }) {
  const post = getPostBySlug(params.slug);
  return new ImageResponse(
    (
      <div style={{ display: "flex" }}>
        <h1>{post.title}</h1>
      </div>
    ),
    { ...size }
  );
}

Looks clean. It's not.

Error 1: Windows path bug

First run in dev on Windows:

TypeError: Invalid URL
input: '.\file:\B:\projects\...\@vercel\og\noto-sans-v27-latin-regular.ttf'

The @vercel/og package tries to load its default font from disk. On Windows with non-standard drive letters, its internal fileURLToPath call breaks. The path gets mangled with the drive letter and it fails to initialize.

Fix that didn't work: switching to edge runtime. That broke fs.readFileSync calls I needed for loading MDX content.

Fix that worked: provide a custom font manually so it never falls back to the broken default. But then I hit the next error.

Error 2: Google Fonts returns HTML

I tried fetching Inter from Google Fonts CDN:

const fontData = await fetch(
  "https://fonts.gstatic.com/s/inter/v18/..."
).then((res) => res.arrayBuffer());

Production log:

Unsupported OpenType signature <!DO

The CDN URL was returning an HTML redirect page starting with <!DOCTYPE instead of a font file. Google Fonts changes their CDN URLs regularly and old direct links stop working.

Fix: stop relying on external fonts. Download Inter Regular and Bold directly into public/fonts/ and load them from disk.

const fontRegular = fs.readFileSync(
  path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf")
);

Error 3: Vercel can't find your public files

Locally everything worked. Deployed. Production log:

ENOENT: no such file or directory
open '/var/task/public/images/og-bg.png'

Vercel serverless functions don't include public/ files by default. Even though they're accessible at your site URL, fs.readFileSync('public/...') fails because those files aren't bundled into the function's file system.

Fix that didn't work: fetching your own domain from within the function. Caused redirect loops and cold start delays.

Fix that worked: tell Next.js to explicitly bundle those files into the serverless function.

Error 4: The next.config.js gotcha

I added this to my config:

outputFileTracingIncludes: {
  "/writing/[slug]/opengraph-image": [
    "./public/images/og-bg.png",
    "./public/fonts/Inter-Regular.ttf",
  ],
}

Build warning:

Invalid next.config.js options detected:
Unrecognized key(s) in object: 'outputFileTracingIncludes'

That syntax is for Next.js 15. On Next.js 14, the option lives under experimental.

Fix: wrap it properly.

experimental: {
  outputFileTracingIncludes: {
    "/writing/[slug]/opengraph-image": [
      "./public/images/og-bg.png",
      "./public/fonts/Inter-Regular.ttf",
    ],
  },
}

Small thing, huge blocker. Config warnings look ignorable until you realize your feature isn't shipping.

Error 5: MDX content also needs bundling

After all the above fixes, images finally rendered. But every OG image showed "Shahriar Avi" as the title, not the actual post title.

The reason: getPostBySlug reads .mdx files from src/content/writing/. Same problem as public/ — those files aren't bundled into the serverless function. The lookup silently returned null and fell back to the default title.

Fix: add MDX files to the same tracing includes.

experimental: {
  outputFileTracingIncludes: {
    "/writing/[slug]/opengraph-image": [
      "./public/images/og-bg.png",
      "./public/avatar/avatar.png",
      "./public/fonts/Inter-Regular.ttf",
      "./public/fonts/Inter-Bold.ttf",
      "./src/content/writing/**/*.mdx",
    ],
  },
}

Deploy. Test. Finally, the actual post title showed up in the preview.

The working setup

For anyone building this today, here's the checklist that would have saved me six hours.

Install:

npm install @vercel/og

Yes, even though next/og exists. The underlying package needs to be a direct dependency for Vercel to bundle it correctly.

Download fonts:

Grab Inter Regular and Bold from the official Inter GitHub. Save to:

public/fonts/Inter-Regular.ttf
public/fonts/Inter-Bold.ttf

Don't rely on Google Fonts CDN. It changes.

Update next.config.js:

experimental: {
  outputFileTracingIncludes: {
    "/writing/[slug]/opengraph-image": [
      "./public/images/og-bg.png",
      "./public/avatar/avatar.png",
      "./public/fonts/Inter-Regular.ttf",
      "./public/fonts/Inter-Bold.ttf",
      "./src/content/writing/**/*.mdx",
    ],
  },
}

Every file the function reads at runtime needs to be listed here.

Create the OG image route:

import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/writing";
import fs from "fs";
import path from "path";
 
export const runtime = "nodejs";
export const contentType = "image/png";
export const size = { width: 1200, height: 630 };
 
function loadAsset(relPath: string) {
  try {
    return fs.readFileSync(path.join(process.cwd(), "public", relPath));
  } catch {
    return null;
  }
}
 
export default async function Image({ params }) {
  const post = getPostBySlug(params.slug);
  const title = post?.title ?? "Shahriar Avi";
  const readingTime = post?.readingTime ?? "";
 
  const bgBuffer = loadAsset("images/og-bg.png");
  const avatarBuffer = loadAsset("avatar/avatar.png");
  const fontRegular = loadAsset("fonts/Inter-Regular.ttf");
  const fontBold = loadAsset("fonts/Inter-Bold.ttf");
 
  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: "80px",
          backgroundImage: bgBuffer
            ? `url(data:image/png;base64,${bgBuffer.toString("base64")})`
            : undefined,
          backgroundColor: "#0a1024",
          backgroundSize: "cover",
          fontFamily: "Inter",
        }}
      >
        <div
          style={{
            color: "#ffffff",
            fontSize: "76px",
            fontWeight: 700,
            lineHeight: 1.1,
          }}
        >
          {title}
        </div>
 
        <div style={{ display: "flex", alignItems: "center", gap: "20px" }}>
          {avatarBuffer && (
            <img
              src={`data:image/png;base64,${avatarBuffer.toString("base64")}`}
              width={72}
              height={72}
              style={{ borderRadius: "18px" }}
            />
          )}
          <div style={{ color: "#ffffff", fontSize: "32px", fontWeight: 700 }}>
            Shahriar Avi
          </div>
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [
        fontRegular && { name: "Inter", data: fontRegular, weight: 400 },
        fontBold && { name: "Inter", data: fontBold, weight: 700 },
      ].filter(Boolean),
    }
  );
}

Add per-post metadata:

In your post page, reference the OG image in generateMetadata:

export async function generateMetadata({ params }) {
  const post = getPostBySlug(params.slug);
  const url = `https://yoursite.com/writing/${post.slug}`;
  return {
    openGraph: {
      title: post.title,
      images: [{ url: `${url}/opengraph-image`, width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      images: [`${url}/opengraph-image`],
    },
  };
}

Testing your OG images

Every social platform caches previews aggressively. If you update your OG image and Facebook still shows the old one, use their debuggers to force a refresh:

  • Facebook: developers.facebook.com/tools/debug
  • X / Twitter: cards-dev.twitter.com/validator
  • LinkedIn: linkedin.com/post-inspector
  • Discord: post in a channel and hover the preview

Don't test in production until you've hit the URL directly in a browser. If /writing/[slug]/opengraph-image returns a 500, no scraper will fix it for you.

What I learned

Dynamic OG images look like a simple feature. In practice, they combine:

  • Runtime image rendering with a font engine
  • Serverless file system quirks
  • Cross-platform path handling
  • Aggressive caching by every social network
  • Silent fallbacks that hide the real error

Each layer has its own gotchas. When something breaks in production, the error message often points at the wrong layer.

The lesson: read every warning at build time, even the ones marked "invalid config". They're not warnings, they're features silently not shipping. That one line about outputFileTracingIncludes being under the wrong key cost me two hours because I skimmed past it.

Ship the boring version first: text on a solid color. Once that works, add fonts. Once fonts work, add images. Once images work, add the layout you actually want. Build up in layers so you know exactly which piece broke.

And if you're on Windows with a project on B:\ drive, just move to C:\. It's not worth the debugging.

more