DEV Community

Ricardo Diaz Miralles
Ricardo Diaz Miralles

Posted on

Next.js opengraph-image applies to one route, not one subtree

I shipped a fix for my broken link previews and made 65 of 66 pages worse. The
build passed. Nothing warned me. The only way I found out was reading the
compiled HTML.

The original bug

Every page on the site declared this:

<meta property="og:image" content="https://example.net/favicon.svg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
Enter fullscreen mode Exit fullscreen mode

An SVG. Facebook, X, LinkedIn and WhatsApp do not render SVG as og:image, so
every link anyone shared appeared with no thumbnail at all. The declared
dimensions were fiction too — a favicon is a small square, not 1200×630.

It came from a default parameter in my metadata helper that nobody had revisited:

export function createPageMetadata({
  title,
  description,
  path = "/",
  image = "/favicon.svg",   // <- here
}) {
Enter fullscreen mode Exit fullscreen mode

I will not pretend this is a subtle one. It is a site whose entire pitch is
"check your images before you publish them", failing at exactly that.

The obvious fix

Next.js has a file convention for this. Drop opengraph-image.tsx in app/,
export a function returning an ImageResponse, and Next generates the PNG at
build time and writes the tags for you:

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

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

export default function Image() {
  return new ImageResponse(<div style={{ /* … */ }}></div>, { ...size });
}
Enter fullscreen mode Exit fullscreen mode

I added that, plus one per route for the five pages that deserved their own
artwork. Removed the hardcoded image default so nothing would override the
generated file. next build — clean. Route list showed the image routes being
prerendered:

├ ○ /opengraph-image
├ ○ /instagram-image-checker/opengraph-image
├ ○ /social-media-image-sizes/opengraph-image
...
Enter fullscreen mode Exit fullscreen mode

Looks finished. It was not.

What actually shipped

Before deploying I grepped the compiled HTML, which is the only reason this
story has a happy ending:

index                          -> /opengraph-image?82cf03fe3b00233a
instagram-image-checker        -> /instagram-image-checker/opengraph-image?2e46…
social-media-image-sizes       -> /social-media-image-sizes/opengraph-image?d5aa…
guides/image-alt-text          -> NO og:image
image-seo-meta-checker         -> NO og:image
open-graph-image-checker       -> NO og:image
es/social-media-image-sizes    -> NO og:image
es/instagram-image-checker     -> NO og:image
Enter fullscreen mode Exit fullscreen mode

Six routes had an image. Every other page had no og:image tag at all
worse than the SVG, because at least the SVG was a URL some scrapers would try.

The controlled test

I did not want to publish a claim I had only seen once in a messy state, so I
reduced it. Branch, one file at the root, nothing else emitting images:

// app/opengraph-image.tsx  — the only image file in the project
export default function Image() {
  return new ImageResponse(<div >ROOT OG</div>, { ...size });
}
Enter fullscreen mode Exit fullscreen mode

Build, then check:

/                       ->  /opengraph-image?29e6d2edd61b209f
/about                  ->  no og:image
/guides                 ->  no og:image
/guides/image-alt-text  ->  no og:image
/image-alt-text-checker ->  no og:image
/es/about               ->  no og:image
Enter fullscreen mode Exit fullscreen mode

Then the question anyone will ask next — does a file in a segment cover that
segment's children? Added app/guides/opengraph-image.tsx and rebuilt:

/guides                      ->  /guides/opengraph-image?321e476533b4f29e
/guides/image-alt-text       ->  no og:image
/guides/image-size-for-web   ->  no og:image
Enter fullscreen mode Exit fullscreen mode

No. app/guides/opengraph-image.tsx covers /guides and nothing beneath it.
The file applies to the one route the file sits in. Next.js 16.2.4.

Why the mental model is wrong

The docs are not wrong here, and it is worth being precise about that. The
API reference
says the convention sets the image "for a route segment", and that you add
the file to "any route segment". Exactly right, both times. I read straight
past it, twice.

The reason it is easy to misread is that the other metadata system in App
Router does cascade. A metadata object exported from a layout merges down into
every page beneath it — that is the whole point of putting metadataBase,
title.template and openGraph.siteName in the root layout once. So you build a
mental model where "metadata set high in the tree applies to everything below",
and then the file convention, which lives in the same feature area and is
described on adjacent doc pages, does not behave that way.

And the failure mode is silent. A missing og:image is not an error. There is no
build warning, no type error, no runtime complaint. The page renders perfectly.
You find out when someone shares a link and it comes out as a grey box, which
might be weeks later.

The fix that scales

The literal fix is one opengraph-image.tsx per route segment. For this project
that meant about 40 files, and doubling again for the /es subtree — plus a
matching twitter-image.tsx in each, or accept that X falls back to og:image.

So instead: one static route that serves every variant, and one default in the
metadata helper.

// app/og/[slug]/route.tsx
import { createOgImage } from "@/lib/ogImage";
import { DEFAULT_OG_SLUG, OG_VARIANTS } from "@/lib/ogVariants";

export const dynamic = "force-static";
export const dynamicParams = false;

export function generateStaticParams() {
  return Object.keys(OG_VARIANTS).map((slug) => ({ slug }));
}

export async function GET(_request: Request, { params }) {
  const { slug } = await params;
  return createOgImage(OG_VARIANTS[slug] ?? OG_VARIANTS[DEFAULT_OG_SLUG]);
}
Enter fullscreen mode Exit fullscreen mode

force-static plus generateStaticParams means these are prerendered at build
time exactly like the file convention would be — the route handler never runs on
a request. Slugs carry the extension (default.png, instagram.png) so the URL
looks like an image to anything that cares.

Then the helper gives every page one by default:

const ogImage = ogImageMeta(ogVariant);   // ogVariant defaults to "default.png"

return {
  openGraph: { /* … */ images: [ogImage] },
  twitter:   { card: "summary_large_image", /* … */ images: [ogImage] },
};
Enter fullscreen mode Exit fullscreen mode

A page that wants its own passes one word:

export const metadata = createPageMetadata({
  title: "Instagram Image Size Checker",
  path: "/instagram-image-checker",
  ogVariant: "instagram.png",
});
Enter fullscreen mode Exit fullscreen mode

One more detail that is easy to get wrong: keep the variant catalogue in a module
that does not import next/og. If your metadata helper imports the file that
imports ImageResponse, every page that reads metadata drags the renderer along
with it. Catalogue and constants in one file, the ImageResponse JSX in another.

Result — 66 real pages, 66 with a PNG:

og:image        https://publishpixel.net/og/instagram.png
og:image:type   image/png
og:image:width  1200
og:image:height 630
Enter fullscreen mode Exit fullscreen mode

What this costs

Two things you give up, and you should decide they are acceptable rather than
discover them:

You write the dimension tags yourself. The file convention reads size and
contentType from your exports and emits og:image:width, og:image:height and
og:image:type for free. Rolling your own route means declaring them in your
metadata — and if you change the canvas size in one place and not the other, you
are back to lying about dimensions, which is the bug I started with. Derive both
from the same constant.

You lose per-route params. app/shop/[slug]/opengraph-image.tsx receives
the route's params and can generate an image from the actual product. A shared
route cannot. If your images are genuinely per-URL — a blog with 400 posts
rendering each title into the card — the file convention is the right tool and
you should use it, one file in the dynamic segment, which covers every URL under
it because they are all the same route.

That is the real dividing line. Per-route images: file convention. A handful
of shared images across many static routes: a shared route beats 40 files.

How to check your own project

After next build, list the prerendered pages that have no og:image:

grep -L 'property="og:image"' $(find .next/server/app -name '*.html')
Enter fullscreen mode Exit fullscreen mode

grep -L prints the files without a match. Anything unexpected in that list is a
page whose links will share as a grey box. Only covers prerendered routes, so
check dynamic ones against a running server.

Worth doing even if you never touch the file convention — the SVG default that
started all this had been in production for months, and no tool I was using
flagged it, because a page with a valid og:image URL pointing at an unsupported
format looks fine to a validator right up until a real scraper tries to render it.


The project is PublishPixel, a set of browser-based
image checks that run locally without uploading anything — I build and maintain
it, and the numbers above come from its production deployment. The page that
sent me looking was the
Open Graph image checker,
which teaches people to get link previews right and was, at the time, shipping
its own preview as an SVG.

If you have hit the opposite version of this — a file convention you expected to
apply to one route and found applied to many — I would like to hear it. I now
assume nothing in App Router cascades until I have checked the compiled HTML.

Top comments (0)