
Next.js Metadata and OG images
Every page you ship eventually gets shared somewhere you don't control: pasted into a Slack channel, tweeted, dropped into a WhatsApp group, or indexed by a search engine that only sees the <head> of your HTML. What shows up in those moments, a real title, a description that makes sense, a preview image that isn't a blank grey box, comes down entirely to metadata. Get it wrong and your carefully built page looks broken the second it leaves your site.
The App Router treats this as a first-class concern rather than an afterthought bolted on with a <Head> component, which is how it worked in the Pages Router. Next.js gives you a proper Metadata API: a way to declare static tags for pages that never change, a way to generate metadata dynamically when it depends on data you don't have until request time, and a set of file conventions that let you drop in a favicon or an Open Graph image without writing a line of markup. This article walks through all three, plus the parts of the system, streaming metadata, generated OG images, and the tradeoffs between them, that the official docs mention only in passing.
The Three Ways to Add Metadata
Before touching any code, it helps to know that everything in this article boils down to three mechanisms, and you'll usually combine at least two of them on a real project:
- The static
metadataobject — export a plain object from alayout.tsxorpage.tsxfile when the title, description, and other tags never change based on data. - The
generateMetadatafunction — an async function you export instead of the object, used when metadata depends on something you have to fetch, like a blog post's title or a product's price. - File conventions — special files like
favicon.ico,opengraph-image.jpg,robots.txt, andsitemap.xmlthat Next.js picks up automatically based on their name and location in theappdirectory, no export needed at all.
Whichever route you take, Next.js compiles the result down to the actual <meta>, <title>, and <link> tags in the rendered HTML. You can always verify what actually got generated by opening your browser's dev tools and looking at the page source, which is worth doing the first time you set any of this up, because it's easy to assume something worked when it silently didn't.
One constraint worth internalizing immediately: both the metadata export and generateMetadata only work in Server Components. If you try to export metadata from a file marked 'use client', Next.js will not pick it up, and you won't get an error telling you why. This trips people up constantly when they're retrofitting metadata onto a page that was built client-first. The fix is almost always to lift the metadata export into the nearest Server Component ancestor, typically the layout.tsx that wraps the client page.
What Next.js Adds for You Automatically
Even a page with zero metadata configuration ships with two tags:
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
The charset tag tells the browser how to interpret the byte stream as text, and the viewport tag is what makes your site render at a sane size on mobile instead of the desktop layout squeezed onto a small screen. These are so foundational that Next.js just always includes them, on every route, regardless of what you configure. You can override the viewport specifically through the generateViewport function if you need custom scaling behavior (disabling zoom for a kiosk-style app, for instance), but it's rare that you'll need to touch this.
Static Metadata: The metadata Object
This is the mechanism you reach for first, and honestly the one you'll use for the majority of your routes. Any static route, marketing pages, an about page, a pricing page, doesn't need to fetch anything to know its own title. You just export a Metadata object:
// app/blog/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Blog",
description: "Thoughts on web development, mostly Next.js.",
};
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
Notice this is exported from a layout.tsx, not just a page.tsx. That's deliberate: metadata defined in a layout applies to every page nested underneath it, and Next.js merges metadata from the root layout down through every layout and page in the route segment, with more specific segments overriding or extending the ones above them. A title set at the root layout becomes the fallback for every page unless that page defines its own.
This merging behavior is genuinely useful once you understand it, but it's also the source of most metadata bugs. If you set description in your root layout thinking it's just a sensible default, and then forget that every single page inherits it unless overridden, you'll end up with your homepage's description showing up in Google's search results for your pricing page too. The practical habit worth adopting: put only truly site-wide fields (site name, default OG image, viewport, theme color) at the root, and be explicit about title and description at the page level for anything that actually gets shared or indexed individually.
There's a specific mechanism for controlling how titles compose across this hierarchy, called a title template:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: "My Site",
template: "%s | My Site",
},
};
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
title: "Understanding React Server Components",
};
With the template in place, the blog post's rendered <title> becomes Understanding React Server Components | My Site, without the child page needing to know or repeat the site name. Skip the template and just set title: "..." directly at the root if you don't want this composition, Next.js won't force it on you.
Dynamic Metadata with generateMetadata
The static object works fine until the metadata itself depends on data you don't have at build time, a blog post's actual title, a product's actual name, a user's actual profile. For that, you export an async function instead:
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then(
(res) => res.json(),
);
return {
title: post.title,
description: post.excerpt,
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then(
(res) => res.json(),
);
return <article>{post.title}</article>;
}
Two things about the signature are worth calling out because they're easy to miss on a skim. First, params is a Promise, not a plain object, in current versions of Next.js, so you await it inside the function body rather than destructuring it directly in the parameter list. If you're used to older Next.js code (or tutorials still floating around from Next.js 13/14), you'll see params accessed synchronously, that pattern no longer works and will throw at runtime. Second, the function receives a parent argument, a promise resolving to the metadata resolved by the segment above this one. This lets you extend rather than replace, for example inheriting the parent's Open Graph images and only overriding the title:
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then(
(res) => res.json(),
);
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
openGraph: {
images: [post.coverImage, ...previousImages],
},
};
}
This is a small thing that saves you from having to re-declare a fallback OG image on every single dynamic route just because you wanted to prepend one image specific to that post.
Streaming Metadata (and Why It's Disabled for Bots)
Here's something that isn't obvious unless you've actually watched the network tab: on a dynamically rendered page, Next.js doesn't necessarily wait for generateMetadata to resolve before it starts sending HTML to the browser. It streams the page's visible content first, then injects the resolved metadata into the <head> once the async function finishes, rather than blocking the entire response on a data fetch that has nothing to do with what the user sees on screen. For a human visitor, this is a straightforward win: they see content sooner, and the <title> tag updates a moment later, usually imperceptibly.
For a crawler that only ever looks at the initial HTML payload and doesn't wait around for JavaScript-driven updates, streamed-in metadata might as well not exist. That's why Next.js automatically detects known bots and crawlers, Twitterbot, Slackbot, Bingbot, and others, by inspecting the User-Agent header, and falls back to blocking the response until metadata resolves for those requests specifically. Regular users get the fast streamed experience; the crawlers that need a complete <head> up front get it.
You don't need to configure anything for this to work correctly out of the box, but if you're seeing a crawler you rely on (an internal SEO tool, a less common social platform) getting empty previews, the fix is usually to add its user agent string to the htmlLimitedBots option in next.config.js, which forces the blocking behavior for any User-Agent matching that pattern:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
htmlLimitedBots: /MyCustomCrawler|SomeOtherBot/,
};
module.exports = nextConfig;
Also worth knowing: prerendered (fully static) pages never stream metadata in the first place, because everything, content and metadata alike, was already resolved at build time. Streaming metadata is purely a dynamic-rendering concern.
Avoiding Duplicate Fetches with React's cache
Look back at the generateMetadata example above and you'll notice the same fetch call to get the post also appears in the page component itself. Naively, that's two network requests for the same data on every single page load, one to build the metadata, one to render the content. React gives you a clean way to collapse these into a single request using the cache function:
// app/lib/data.ts
import { cache } from "react";
import { db } from "@/app/lib/db";
// getPost is called twice below, but the underlying query runs only once per request
export const getPost = cache(async (slug: string) => {
return db.query.posts.findFirst({ where: eq(posts.slug, slug) });
});
// app/blog/[slug]/page.tsx
import { getPost } from "@/app/lib/data";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title, description: post.description };
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <div>{post.title}</div>;
}
cache memoizes the function's return value for the lifetime of a single render pass, keyed on its arguments, so calling getPost("my-slug") from both generateMetadata and the page component resolves to one actual database query or fetch, not two. This is easy to skip when you're moving fast, and the cost of skipping it is invisible in local development (nobody notices one extra query on localhost) but shows up as real, measurable latency and database load in production once traffic picks up. If you're fetching the same data in both places, wrapping it in cache should become a reflex, not an afterthought.
Don't confuse this cache from react with unstable_cache or the "use cache" directive from Next.js itself, they solve a related but different problem (persisting data across requests and deployments, versus deduplicating within one render). React's cache resets on every new request; it's purely a request-scoped dedupe tool.
File-Based Metadata: Favicons and Icons
Some metadata doesn't need code at all. Drop a favicon.ico file directly into the root of your app directory, and Next.js picks it up automatically, no import, no export, nothing to configure:
app/
├── favicon.ico
├── layout.tsx
└── page.tsx
Next.js also recognizes icon.png (or .jpg, .svg) and apple-icon.png using the same convention, and it supports nested icons too: an icon.png inside app/blog/ overrides the root one for every route under /blog, which is handy if a section of your site (a docs subdomain-style path, a distinct product area) warrants its own visual identity in the browser tab. If you need the icon to be generated rather than a static file, you can also export a default function from icon.tsx that returns an ImageResponse, the same mechanism covered below for OG images, though for most sites a static .ico or .png is simpler and perfectly sufficient.
Static Open Graph Images
An Open Graph image is what shows up as the big preview picture when a link gets shared on Slack, Twitter/X, LinkedIn, iMessage, or basically any platform that unfurls links. If you've ever pasted a URL and gotten back a blank grey box instead of a real image, that site either has no og:image tag, or a broken one.
The static route is identical in spirit to favicons: drop an opengraph-image.jpg (or .png, .gif) into the app folder, and Next.js wires up the correct <meta property="og:image"> tag automatically.
app/
├── opengraph-image.jpg
├── layout.tsx
└── page.tsx
And just like icons, this respects folder nesting. An opengraph-image.jpg placed inside app/blog/ becomes the fallback OG image for every post under /blog, while a more deeply nested one, say inside app/blog/[slug]/, takes precedence for that specific route over anything above it in the tree. This gives you a clean way to have one default share image for the whole site, a slightly different one for the blog section, and a genuinely unique one per post, without any JavaScript, just file placement.
The catch with a purely static image: it can't reflect the content of the page. A static opengraph-image.jpg sitting in app/blog/[slug]/ is the same image for every single blog post, which defeats a lot of the purpose if your goal is a preview that actually shows the post's title.
Generated Open Graph Images with ImageResponse
This is where things get genuinely interesting, and it's the part of the Metadata API that feels closest to a real feature rather than just markup convenience. ImageResponse, imported from next/og, lets you generate an image on the fly using JSX and CSS, meaning your OG image can pull in the post's actual title, author name, or even a snippet of content, rendered as a real PNG.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/app/lib/data";
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 getPost(slug);
return new ImageResponse(
<div
style={{
fontSize: 64,
fontWeight: 700,
background: "linear-gradient(135deg, #1e293b, #0f172a)",
color: "white",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "80px",
}}
>
<div style={{ fontSize: 28, opacity: 0.7, marginBottom: 24 }}>
MY BLOG
</div>
<div>{post.title}</div>
</div>,
);
}
That 1200x630 size isn't arbitrary, it's the de facto standard OG image dimension that most platforms expect, and using it avoids awkward cropping when Twitter or LinkedIn resizes your image into their own preview card layout. Naming this file opengraph-image.tsx (instead of .jpg) inside app/blog/[slug]/ is what tells Next.js to treat it as a dynamic generator rather than a static asset, one function, and every single blog post gets a unique, on-brand share image with zero design tool involved.
A few practical constraints worth knowing before you get ambitious with the layout:
Only flexbox and a limited CSS subset work. ImageResponse renders through Satori, which converts your JSX and inline styles into an SVG, then rasterizes that to a PNG. It does not implement a full browser rendering engine. display: grid will not work. Complex selectors, pseudo-elements, and most layout modes beyond flexbox and absolute positioning are unsupported. If your design needs a grid, restructure it as nested flex containers instead, it's more annoying to write but it's the actual constraint of the tool.
Custom fonts need to be loaded explicitly, as ArrayBuffer data passed into the fonts option, they don't inherit from your site's font-loading setup (next/font doesn't apply here, since this isn't a normal React render tree in a browser). Fetching a .ttf file and passing its bytes in is the standard pattern.
Test with the Vercel OG Playground before wiring this into your actual project. It's a live sandbox for exactly this JSX-to-image pipeline, and iterating there is much faster than redeploying to see whether your padding looks right.
Twitter Cards: A Separate Tag, Same Image
Open Graph and Twitter's card system are technically two different specs with two different sets of meta tags, og:image versus twitter:image, even though in practice most platforms other than X/Twitter itself just read the Open Graph tags. Next.js follows the same file convention for both: alongside opengraph-image.jpg, you can add a twitter-image.jpg (or .tsx for a generated version) in the exact same location, and Next.js emits the corresponding twitter:image tag automatically.
If you don't provide a twitter-image at all, Next.js falls back to reusing whatever opengraph-image resolved to, so for the majority of projects you genuinely don't need to maintain two separate images. The case where it's worth splitting them is when you want a different aspect ratio or crop for Twitter's card style specifically. You control which card style renders through the twitter field in your metadata object:
export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
title: "My Blog",
description: "...",
},
};
summary_large_image is the wide, banner-style card most sites want, the alternative, summary, renders a small square thumbnail beside the text instead, which is a better fit if your image is closer to 1:1 than the 1200x630 landscape ratio.
Verifying What You Actually Shipped
Because none of this is visible in your own browser tab, it's easy to ship broken metadata and not notice for weeks, until someone mentions the Slack preview looked wrong. A few concrete ways to check before you rely on "it should work":
View the actual rendered HTML, not the React tree. Right-click a live page, choose "View Page Source" (not "Inspect", which shows the live DOM after hydration), and confirm the <title>, <meta name="description">, and <meta property="og:image"> tags contain what you expect, with a fully-qualified absolute image URL.
Use a link debugger that simulates the actual crawler, since your own browser doesn't behave like Slackbot or Twitterbot. Facebook's Sharing Debugger and Twitter's (now X's) Card Validator both fetch your URL server-side and show you exactly what tags they parsed, which catches metadataBase and caching issues that look completely fine when you load the page normally.
Remember that social platforms cache aggressively. If you fix a broken OG image and the preview still looks wrong when you re-share the link, that's very likely a stale cache on the platform's end, not a bug in your code. Most debugger tools (including the two above) have an explicit "scrape again" or "refresh" action that forces a re-fetch, use it before assuming your fix didn't work.
Practical Notes the Docs Don't Spell Out
A handful of things aren't wrong in the official docs, they're just not emphasized, and each one has burned real projects:
Relative vs. absolute URLs in Open Graph images. If you specify an OG image by URL rather than by file convention (for instance, returning openGraph: { images: ['/og/custom.png'] } from generateMetadata), some platforms fail to resolve a relative path correctly when scraping your page, since they don't necessarily know your domain the way a browser does. Set metadataBase in your root layout's metadata (metadataBase: new URL('https://yoursite.com')) so Next.js can resolve every relative metadata URL, images included, into an absolute one automatically. Skipping this is the single most common reason an OG image works perfectly in local dev but shows up broken once deployed.
generateMetadata runs before your page renders, and it blocks that render on dynamic routes unless streaming is in play. If your metadata fetch is slow (an unindexed database query, a flaky third-party API), you're adding that latency to the page's time-to-first-byte in every case where streaming doesn't apply, or to the crawler experience even when it does. Treat the data your metadata depends on with the same performance discipline you'd apply to data the page itself needs.
Metadata doesn't automatically inherit from the page's actual rendered content. Because generateMetadata and your page component are two separate function calls (even when memoized with cache, they're still two distinct invocations), there's nothing structurally stopping the two from drifting out of sync if you edit one and forget the other. If your <h1> says one thing and your <title> says another because someone updated the render logic but not the metadata function, nothing will warn you, it'll just ship that way.
Static and dynamic OG images can silently conflict. If you have both a static opengraph-image.jpg and a dynamic opengraph-image.tsx in the same folder, that's invalid, and precedence rules for file-based conventions generally assume you pick one. Keep this to one mechanism per route segment to avoid ambiguity.
Key Takeaways
| Scenario | Mechanism |
|---|---|
| Fixed title/description for a static page | Export a Metadata object from layout.tsx or page.tsx |
| Title/description depends on fetched data | Export an async generateMetadata function |
| Same data needed in metadata and the page body | Wrap the fetcher in React's cache to dedupe requests |
| Title composition across nested routes | Use a title.template in the parent layout's metadata |
| Site favicon | Drop favicon.ico (or icon.png) in the app root or a subfolder |
| Fixed share image, same for every page in a section | Static opengraph-image.jpg file |
| Unique share image per dynamic route (e.g. per blog post) | opengraph-image.tsx exporting an ImageResponse |
| OG image or metadata URL not resolving correctly in production | Set metadataBase in the root layout |
Crawler receiving an empty <head> | Add its User-Agent to htmlLimitedBots in next.config.js |
The Metadata API earns its keep by turning what used to be an easy-to-forget manual task, remembering to set a title tag, remembering to generate a share image, into something the framework handles structurally: file conventions for the parts that don't change per request, a proper async function for the parts that do, and automatic merging so you're not repeating the same boilerplate on every route. Get metadataBase and the static-versus-dynamic OG image decision right early, and the rest of this system mostly takes care of itself.


