Next.js Open Graph: Fixing Metadata and Share Images
Next.js has a built-in way to describe each page to sharing platforms, but a few of its rules catch developers out. Image addresses come out relative, or pointing at localhost. A page that sets one Open Graph field silently loses every default from the layout. Tags set in a client component never reach the HTML.
A generated share image works locally and fails in production. The people who see the damage are everyone who shares your links. This guide covers the App Router Metadata API, image files and generated images, the older Pages Router, and how to check what crawlers actually receive.
How Next.js produces Open Graph tags
In the App Router, you never write <meta property="og:..."> by hand. Each layout
or page file on the server exports either a static metadata object or an async
generateMetadata function. Next.js walks from the root layout down to the page, merges the
objects, and prints the result in the head of the HTML it sends. The openGraph key becomes
the og: tags and the twitter key becomes the X card tags.
Three rules explain nearly every Open Graph bug in a Next.js site:
- Relative image paths are resolved against
metadataBase. If it is not set, Next.js falls back to a default, typicallyhttp://localhost:3000in development or a deployment address on some hosts, and prints a warning during the build. Crawlers cannot fetch localhost. - Merging is shallow. When a page defines
openGraph, that whole object replaces the layout'sopenGraph. It is not combined field by field. A page that sets onlyopenGraph.titleloses the layout's image, site name, locale and type. - Metadata is a server feature. A file marked
'use client'cannot export metadata, and anything set in the browser with an effect or a head manager after the page loads is invisible to crawlers, which read the HTML and do not run your JavaScript.
On top of that, file conventions let you drop an opengraph-image file into a route folder,
or write one as code, and Next.js adds the image tags for you. File-based images take priority over images
set in the metadata object, which is useful but also a source of "why is this image not
changing" confusion.
Seeing what crawlers receive
Do not debug Open Graph in the browser's element inspector. It shows the page after React has run, which is not what a crawler sees. Fetch the raw HTML from the deployed site instead:
curl -sL -A "facebookexternalhit/1.1" https://example.com/blog/launch-notes \
| grep -oE '<meta (property|name)="(og|twitter|article):[^"]+"[^>]*>'
Read the output against this list of symptoms:
| What you see | Likely cause |
|---|---|
og:image starts with / or http://localhost:3000 | metadataBase
missing from the root layout |
og:image points at a preview deployment address | Fallback base used because
metadataBase is not set to the production domain |
Title present but no og:image, og:site_name or og:type | The
page's openGraph object replaced the layout's |
| Every page shows the home page title and image | Only the root layout sets openGraph; pages set nothing |
No og: tags at all on a page | Metadata exported from a client component, or tags added in the browser |
| Tags appear, but after the opening of the body | Streamed metadata served to a crawler Next.js did not recognise |
og:image address returns 307, 401 or an HTML page | Middleware, authentication or a password on the deployment intercepting the image route |
Then request the image address itself with curl -sI. It should return 200 and an image/png
or image/jpeg content type without cookies.
The fix, step by step
Step 1
Fetch the served head as a crawler would
Run a page through the OG / Twitter Tag Generator to see which tags are
missing or invalid, then fetch the same address from the production site with curl and a crawler user
agent, and list every og:, twitter: and article: tag in the response.
Compare a blog post, a product or service page and the home page, because in Next.js the layout and each
page type often behave differently, and the pattern tells you which rule is being broken.
Step 2
Set metadataBase in the root layout
In
app/layout.tsx, add metadataBase: new URL('https://example.com') to the exported
metadata, using the canonical production origin, ideally read from an environment variable such as NEXT_PUBLIC_SITE_URL
so staging can use its own address. Every relative image path and openGraph.url is then
turned into a full https address. Rebuild and confirm the build no longer prints the warning about metadataBase
not being set.
Step 3
Build one shared openGraph default
Create
a small module, for example app/shared-metadata.ts, that exports the Open Graph fields every
page should carry: siteName, locale set to en_GB, type set to
website and a default image with width, height and alt. Import it in the root layout. Because page metadata
replaces the layout object rather than merging into it, every page that sets its own openGraph must also
spread this default first.
Step 4
Return full openGraph data from generateMetadata
In dynamic routes such as app/blog/[slug]/page.tsx, export an async
generateMetadata that loads the post, spreads the shared defaults, then sets the title,
description, url, type and images for that post. In current Next.js versions params is a
promise, so await it before reading the slug. Return a sensible fallback or call notFound()
for missing posts, rather than letting the function throw.
Step 5
Add an opengraph-image file or route
For a fixed image, place opengraph-image.jpg or a PNG in the route folder,
with an opengraph-image.alt.txt beside it. For per-page images, create opengraph-image.tsx
that exports size, contentType, alt and a default function returning
an ImageResponse from next/og. Remember that a file image overrides any images
set in the metadata object for that route and every route nested below it, so check older image settings.
Step 6
Set the X card and check the twitter object
Add a twitter object with card: 'summary_large_image' to the
shared defaults and spread it wherever a page sets its own twitter fields, because that object is also
replaced rather than merged. Add site and creator handles if you have them.
X falls back to the Open Graph title, description and image, so a missing twitter image is not fatal,
but a missing card type often means a small thumbnail.
Step 7
Unblock image routes and verify in production
Make sure middleware, authentication and deployment protection let crawlers fetch
pages and the generated image routes without cookies, for example by excluding opengraph-image
and twitter-image paths from the middleware matcher. Deploy, fetch the head and the image
address with curl again, run the generator once more, and use the Facebook and LinkedIn inspectors to
replace any previews they cached while the tags were broken.
Working App Router code
The root layout
// app/shared-metadata.ts
export const baseOpenGraph = {
siteName: 'Harbour Tide Studio',
locale: 'en_GB',
type: 'website' as const,
images: [{ url: '/images/share-default-1200x630.png', width: 1200, height: 630, alt: 'Harbour Tide Studio logo on a sea-blue background' }],
};
export const baseTwitter = { card: 'summary_large_image' as const, site: '@harbourtide_example' };
// app/layout.tsx
import type { Metadata } from 'next';
import { baseOpenGraph, baseTwitter } from './shared-metadata';
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'),
title: { default: 'Harbour Tide Studio', template: '%s | Harbour Tide Studio' },
openGraph: { ...baseOpenGraph, url: '/' },
twitter: baseTwitter,
};
A dynamic post page
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { baseOpenGraph, baseTwitter } from '../../shared-metadata';
import { getPost } from '@/lib/posts';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return {
title: post.title,
description: post.summary,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
...baseOpenGraph,
type: 'article',
url: `/blog/${slug}`,
title: post.shareTitle ?? post.title,
description: post.summary,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [`/team/${post.authorSlug}`],
},
twitter: { ...baseTwitter, title: post.shareTitle ?? post.title, description: post.summary },
};
}
Two details are easy to miss. The canonical and openGraph.url use the same path, so they
cannot drift apart. And this page does not set images: if the folder contains an opengraph-image
file, that supplies the image; if not, the spread default does. If you set images here and
also have a file, the file wins.
getPost is also called by the page component, Next.js deduplicates
identical fetch requests during a render. For database calls, wrap the loader in React's
cache function so metadata and page share one query.Generated share images with ImageResponse
An opengraph-image.tsx file in a route folder turns JSX into a PNG. It is the cleanest
way to give every post its own card with the title written on it.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const alt = 'Harbour Tide Studio blog post card';
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
(
<div
tw="flex h-full w-full flex-col justify-center bg-slate-900 px-24 text-white"
>
<div tw="text-3xl opacity-70">Harbour Tide Studio</div>
<div tw="mt-6 text-7xl font-bold leading-tight">{post?.title ?? 'Blog'}</div>
</div>
),
{ ...size }
);
}
Things that go wrong with generated images:
- Layout support is limited. The renderer supports a subset of CSS built around flexbox.
CSS grid and many advanced properties do not work, and every element with more than one child needs
display: flex. - Fonts must be loaded explicitly. Custom fonts are passed as data in TTF, OTF or WOFF form; WOFF2 is not supported. Load only the weights you use, because the documentation sets a size limit on everything the route bundles.
- Long titles overflow. Clamp the title length or reduce the font size for long headlines so text stays inside the card and within the safe central area.
- Declared size must match. The
sizeexport becomesog:image:widthandog:image:height. Pass the same values toImageResponseso the drawn canvas matches the declaration. - Static export. With
output: 'export'there is no server at request time, so image routes for dynamic segments must be generated at build time withgenerateStaticParams.
The image address Next.js prints includes a short hash query string. That helps with caches, because a changed image gets a new address, but it also means you should never hard-code the address elsewhere.
The Pages Router and older projects
Projects still on the pages/ directory do not have the Metadata API. Tags go in next/head,
and the page's data must be available during server rendering through getStaticProps or
getServerSideProps, not fetched in the browser after load.
// pages/blog/[slug].tsx
import Head from 'next/head';
export default function Post({ post, siteUrl }) {
const url = `${siteUrl}/blog/${post.slug}`;
return (
<>
<Head>
<meta property="og:type" content="article" key="og:type" />
<meta property="og:url" content={url} key="og:url" />
<meta property="og:title" content={post.title} key="og:title" />
<meta property="og:image" content={`${siteUrl}${post.cardPath}`} key="og:image" />
<meta name="twitter:card" content="summary_large_image" key="twitter:card" />
</Head>
{/* page content */}
</>
);
}
The key props matter. If _app.tsx prints site-wide defaults with the same
keys, the page's tags replace them instead of producing duplicates. Without keys, the head ends up with
two og:title tags and platforms pick whichever they meet first. Build absolute addresses
yourself here, because there is no metadataBase.
Edge cases specific to Next.js
- Streaming metadata. Recent versions can stream the result of
generateMetadatafor ordinary browsers, so it arrives after the first part of the page. For recognised crawlers Next.js waits and puts the tags in the head. If a less common preview service receives tags outside the head, its user agent can be added to thehtmlLimitedBotssetting innext.config. - Internationalised routes. With
app/[locale]/, setopenGraph.localefrom the route parameter, such as en_GB or fr_FR, rather than hard-coding it in the root layout. - Preview deployments. If each preview build inherits the production
metadataBase, shared preview links show production images, which is usually fine. If it falls back to the preview address, those images may be behind deployment protection. - Trailing slashes. With
trailingSlash: true, makeopenGraph.urland the canonical include the slash too, so both match the address the site actually serves. - Images in
public/. A path like/images/card.pngworks oncemetadataBaseis set. Changing the file but not its name leaves platforms and CDNs holding the old version, so rename changed images.
1. Why is my Next.js og:image a relative URL or localhost?
Because metadataBase is not set in the root layout. Next.js resolves relative image paths and openGraph.url against metadataBase, and without it falls back to a default such as http://localhost:3000, printing a warning during the build. Crawlers cannot fetch those addresses, so the card has no image. Add metadataBase: new URL with your production origin to the metadata exported from app/layout.tsx, ideally read from an environment variable, then rebuild and check the served HTML.
2. Why does my page lose the layout Open Graph image when I set a title?
Next.js merges metadata shallowly. When a page exports an openGraph object, it replaces the layout's openGraph object completely instead of adding to it, so fields the page does not repeat, such as images, siteName, locale and type, disappear. The documented pattern is to keep shared fields in a separate module and spread them into every openGraph object you define. The same rule applies to the twitter object and other nested metadata fields.
3. Should I use opengraph-image files or openGraph.images in metadata?
Either works. A file, whether a static image or an opengraph-image.tsx route, is convenient because Next.js adds the address, type, width, height and alt for you and applies it to that route and everything below it. openGraph.images is better when the image comes from a CMS field. Avoid mixing them for the same route without meaning to, because a file-based image takes priority over images set in the metadata object.
4. Can I use @vercel/og with the App Router?
The ImageResponse class is now built into Next.js and imported from next/og, so a separate @vercel/og install is not needed for current App Router projects. It uses the same rendering engine, which turns a subset of JSX and CSS into a PNG. The @vercel/og package is still useful outside Next.js or in older projects. Either way, check the documented limits on CSS support, font formats and bundle size before designing an elaborate card.
5. Why are my Open Graph tags missing when the page is a client component?
A file marked use client cannot export metadata or generateMetadata; those only work in server components. Setting tags from the browser with an effect or a head library after the page loads does not help either, because link preview crawlers read the HTML and do not run JavaScript. Keep the page file as a server component that exports the metadata, and move the interactive parts into child client components that it renders.
6. Do I need separate twitter tags in Next.js?
You need at least the card type. X falls back to the Open Graph title, description and image, so you do not have to repeat them, but without twitter:card set to summary_large_image a link is more likely to show as a small card. Add card, and site or creator handles if you use them, to the twitter object in your shared defaults. Remember that a page-level twitter object replaces the layout's, so spread the defaults there too.
7. How do I stop middleware from blocking my OG images?
If middleware redirects visitors without a session cookie to a login page, crawlers fetching your pages or opengraph-image routes are redirected too and receive HTML instead of an image. Exclude those paths in the middleware matcher, or return early for them, so they are served publicly. Check with curl -sI on the image address: it should return 200 with an image content type. Deployment protection on preview builds causes the same symptom.
The OG / Twitter Tag Generator builds the Open Graph and X card block for your page, validates it against the requirements of Facebook, LinkedIn and X, predicts how the preview will render and flags every tag that is missing or invalid.
Open Open Graph Fixes โ