Dynamic Metadata with the Next.js Metadata API

generateMetadata is the App Router’s answer to data-driven SEO: an async function, colocated with a route segment, that fetches the route’s data and returns the metadata Next renders into the head — server-side, in the response. Because it runs on the server, crawlers and social scrapers see the resolved tags without executing any client JavaScript. This is the implementation detail under Next.js App Router SEO.

Step-by-step fix

  1. Export an async generateMetadata from the segment.

    // app/blog/[slug]/page.jsx
    export async function generateMetadata({ params }) {
      const post = await getPost(params.slug);
      return {
        title: post.title,
        description: post.summary,
        alternates: { canonical: `https://example.com/blog/${params.slug}` },
        openGraph: { title: post.title, images: [post.ogImage] },
        twitter: { card: 'summary_large_image' },
      };
    }
  2. Let Next dedupe the fetch. Identical fetch calls in generateMetadata and the component are deduplicated within a request, so fetching the post in both is not a double round-trip.

    // ❌ Separate clients/uncached fetches → duplicate work, slower TTFB
    // ✅ Use fetch() so Next dedupes between generateMetadata and the page
    async function getPost(slug) {
      const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 } });
      return res.json();
    }
  3. Generate per-route OG images. Add an opengraph-image to the segment so each route has a unique image.

    // app/blog/[slug]/opengraph-image.jsx
    import { ImageResponse } from 'next/og';
    export default async function Image({ params }) {
      const post = await getPost(params.slug);
      return new ImageResponse(<div style={{ fontSize: 64 }}>{post.title}</div>);
    }
  4. Render JSON-LD in the server component body so structured data ships in the response.

    export default async function Page({ params }) {
      const post = await getPost(params.slug);
      const ld = { '@context': 'https://schema.org', '@type': 'Article', headline: post.title };
      return (<>
        <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
        <Article post={post} />
      </>);
    }

Validation

  • curl of the route shows the resolved title, canonical, and OG tags in the response.
  • GSC URL Inspection rendered HTML matches the live head.
  • OG image renders correctly in social debuggers and is unique per route.
  • Rich Results Test validates the Article JSON-LD.

Reference

// app/products/[id]/page.jsx — metadata + structured data, fully server-rendered
async function getProduct(id) {
  const res = await fetch(`https://api.example.com/products/${id}`, { next: { revalidate: 600 } });
  return res.json();
}
export async function generateMetadata({ params }) {
  const p = await getProduct(params.id); // deduped with the page fetch
  return {
    title: `${p.name} — Example`,
    description: p.summary,
    alternates: { canonical: `https://example.com/products/${p.id}` },
    openGraph: { title: p.name, images: [p.image] },
  };
}

Frequently Asked Questions

Does generateMetadata run on the server? Yes. generateMetadata runs on the server during rendering, so the tags it returns are present in the HTML response. That is why it is reliable for crawlers and social scrapers that do not execute client JavaScript.

How do I generate per-route Open Graph images in Next.js? Use an opengraph-image file in the route segment, or the ImageResponse API in a route handler, to render an image per route at request or build time. Reference it from generateMetadata so each page ships a unique OG image.

← Back to Next.js App Router SEO