Concept
The beginner framing: the Metadata APIs let you define <head> content, title, description, Open Graph tags, favicons, declaratively, and Next.js generates the corresponding tags automatically.
The precise mental model: there are two ways to define metadata, and the choice between them is really a choice about whether your metadata needs data. A static metadata object, exported from a layout.tsx or page.tsx, is for metadata that never changes. A generateMetadata async function is for metadata that depends on data, and critically, both are Server Component-only features.
// Static, app/blog/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Blog",
description: "Thoughts on software.",
};// Generated, 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/blog/${slug}`).then((r)
Two default <meta> tags, charset and viewport, are always present even if you define no metadata at all.
Streaming metadata, and why it can silently block rendering
For dynamically rendered pages, Next.js streams metadata separately by default, injecting it into <head> once generateMetadata resolves, without blocking the page's visual content from streaming first. This is disabled for known bots/crawlers (detected via User-Agent, Twitterbot, Slackbot, Bingbot, etc.) that expect metadata already present in the initial HTML <head>, since some crawlers don't execute JavaScript to pick up streamed-in tags. You can customize or fully disable this behavior via the htmlLimitedBots config option. Prerendered (fully static) pages don't stream metadata at all, since it's already resolved at build time.
Avoiding duplicate fetches: React.cache
A very common pattern is needing the same data for both generateMetadata and the page body, wrap the fetch in React's cache() so it executes once and is reused by both:
import { cache } from "react";
export const getPost = cache(async (slug: string) => {
return db.query.posts.findFirst({ where: eq(posts.slug, slug) });
});export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // first call, actually fetches
return { title: post.title };
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug); // SAME request, reuses the memoized result
return <h1>{
File-based metadata
Instead of (or alongside) code, a set of special files provide metadata directly: favicon.ico/icon.jpg/apple-icon.jpg, opengraph-image.jpg/twitter-image.jpg, robots.txt, sitemap.xml, each can be static files or programmatically generated. A more specific file (e.g., app/blog/opengraph-image.jpg) takes precedence over a less specific one higher in the folder tree (app/opengraph-image.jpg).
Generated Open Graph images with ImageResponse
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/data";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return new ImageResponse(
<div style={{ fontSize: 128, display: "flex", alignItems:
ImageResponse renders JSX and CSS to a PNG using Satori and resvg, but it only supports flexbox and a subset of CSS properties; display: grid and similarly advanced layouts won't work.
Try It
Predict what happens before checking the solution.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://slow-api.example.com/${slug}`).then((r) => r.json());
return { title: post.title };
}
export default async function Page({ params }: Props) {
return <h1>Fast content, unrelated to the slow fetch above</h1>;
}The fetch inside generateMetadata is slow (2 seconds). Does the page's own fast content show up before those 2 seconds pass?
Solution
For a dynamically rendered page, yes, streaming metadata means the page's visual content can stream in before generateMetadata resolves, with the <title> tag itself streamed in and injected once ready, rather than blocking everything. However, for a bot/crawler request (detected by User-Agent) or a fully static/prerendered page, this streaming behavior doesn't apply, metadata streaming is specifically disabled for bots that need <head> populated upfront, and prerendered pages have already resolved everything at build time regardless.
Implement It Yourself
Model the decision between static metadata and generateMetadata, plus the React.cache deduplication:
function chooseMetadataStrategy({ dependsOnData }) {
return dependsOnData
? "generateMetadata (async function, can fetch, receives params/searchParams)"
: "export const metadata (static object, no fetching, evaluated once)";
}
function memoize(fn) {
const cache = new Map();
return async (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args)); // cache the PROMISE immediately
return cache.get(key);
This mirrors React.cache's core value exactly, the same request-scoped memoization pattern already covered for Data Fetching: cache the promise itself, so any caller within the same render/request gets the identical result without a duplicate underlying fetch.
Under the Hood
generateMetadata and the static metadata export being Server-Component-only features is a direct consequence of Server Components, generating <head> tags (especially ones that fetch data) requires the same "runs on the server, can safely fetch, never ships client JS" guarantees that make Server Components the right home for this. And the React.cache deduplication pattern is the same request-scoped memoization mechanism covered in Data Fetching, a promise cached once, shared by every caller within that request, whether the caller is generateMetadata or the page itself.
Common Mistakes
1. Fetching the same data twice, once for metadata, once for the page
export async function generateMetadata({ params }: Props) {
const post = await fetch(`/api/posts/${params.slug}`).then((r) => r.json()); // fetch #1
return { title: post.title };
}
export default async function Page({ params }: Props) {
const post = await fetch(`/api/posts/${params.slug}`).then((r) => r.json()); // ❌ fetch #2, redundant
Without wrapping the shared fetch in React.cache (or relying on fetch's own automatic request memoization for identical calls), this pattern silently doubles the network cost for every page load.
2. Assuming streaming metadata applies universally
Covered in Try It, bots/crawlers get non-streamed metadata (already in <head>) since many don't execute JS to pick up a streamed-in tag, and prerendered/static pages never stream metadata at all since it's resolved at build time.
3. Trying advanced CSS layouts (display: grid) in ImageResponse
ImageResponse supports flexbox and a subset of CSS properties only, reaching for grid or other unsupported layout modes will silently fail to render as expected.
Best Practices
- Use the static
metadataobject whenever metadata doesn't depend on data, it's simpler and requires no async work at all. - Wrap any data shared between
generateMetadataand the page body inReact.cacheto guarantee a single underlying fetch/query per request. - Let more specific file-based metadata (a route-level
opengraph-image.jpg) override a general one rather than trying to conditionally generate one dynamic image for every route. - Test social share previews against real crawler behavior, keeping in mind streaming metadata is deliberately disabled for many of them.
Performance Tips
- Streaming metadata lets a data-dependent
generateMetadataavoid blocking the page's own visible content, but only for genuinely dynamic pages seen by regular browsers, not bots or prerendered routes. ImageResponse-generated OG images are computed on request (unless cached), for content that rarely changes, pairing this with"use cache"(see Caching) avoids recomputing the same image repeatedly.
