Type something to search...
How to Use the Metadata API for Dynamic SEO Tags in the Next.js App Router?

How to Use the Metadata API for Dynamic SEO Tags in the Next.js App Router?

In the Pages Router, SEO tags lived inside next/head. Every page imported Head, wrote its own title and meta tags by hand, and it was easy to forget a canonical URL on one page or duplicate a description on another. When the App Router arrived, next/head stopped working in the app directory, and many developers moving over were left asking where the tags are supposed to go now.

The answer is the Metadata API. Instead of writing tags, you export a typed object or an async function from a layout or page, and Next.js generates the correct head elements, merges them across nested layouts, deduplicates them, and resolves relative URLs for you. It handles static pages, database-driven pages, and everything in between.

This article covers the Metadata API in the Next.js 16 App Router: static metadata objects, title templates, metadataBase, dynamic generateMetadata for routes like blog posts and products, Open Graph and Twitter cards, canonical and hreflang links, robots directives, the separate viewport export, how metadata merges across layouts, streaming metadata, file-based metadata, structured data, and how all of it behaves with Cache Components.

The Three Ways to Define Metadata

The App Router gives you three tools, and most sites use all of them:

ApproachUse it forWhere it lives
metadata objectPages and layouts whose tags do not depend on datalayout.tsx or page.tsx
generateMetadata functionTags that depend on params or fetched datalayout.tsx or page.tsx
File conventionsFavicons, Open Graph images, robots.txt, sitemapSpecial files in the app folder

Two rules apply to the first two:

  • They work only in Server Components. A file marked "use client" cannot export metadata.
  • A single route segment can export either metadata or generateMetadata, not both.

If your page needs client-side interactivity, keep page.tsx as a Server Component that exports metadata and move the interactive parts into a separate client component it renders.

Static Metadata in the Root Layout

Start with the root layout. This is where you define site-wide defaults that every page inherits:

// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  metadataBase: new URL("https://websolutionmaster.com"),
  title: {
    default: "Web Solution Master",
    template: "%s | Web Solution Master",
  },
  description:
    "Practical guides on Next.js, WordPress, DNS, analytics, and web typography.",
  applicationName: "Web Solution Master",
  authors: [
    { name: "Sajjad", url: "https://websolutionmaster.com/authors/sajjad" },
  ],
  openGraph: {
    type: "website",
    siteName: "Web Solution Master",
    locale: "en_US",
    images: ["/images/og-default.png"],
  },
  twitter: {
    card: "summary_large_image",
  },
  alternates: {
    canonical: "/",
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

A few pieces deserve explanation.

metadataBase

Open Graph images, canonical URLs, and alternate links must be absolute URLs. metadataBase lets you write relative paths everywhere else and have Next.js resolve them against your domain. With the base above, "/images/og-default.png" becomes https://websolutionmaster.com/images/og-default.png.

Set it once in the root layout. If you use a relative URL in a URL-based field without a metadataBase, the build fails. To keep preview deployments working, read the base from an environment variable:

// lib/site.ts
export const siteUrl =
  process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// app/layout.tsx
import type { Metadata } from "next";
import { siteUrl } from "@/lib/site";

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  // ...
};

Title templates

title.template adds a suffix (or prefix) to titles defined in child segments. With the template "%s | Web Solution Master", a page that sets title: "Contact" renders Contact | Web Solution Master.

The rules are easy to trip over:

  • A template requires a default, which is used by child pages that do not set a title.
  • The template applies to child segments, not to the segment that defines it. A template in app/blog/layout.tsx does not apply to the title in app/blog/page.tsx, because both files belong to the same /blog segment, but it does apply to app/blog/[slug]/page.tsx and every other nested page.
  • A template in page.tsx does nothing, because a page has no children.

To opt a single page out of the template, use absolute:

// app/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    absolute: "Web Solution Master: Next.js, WordPress, and DNS Guides",
  },
};

export default function HomePage() {
  return <h1>Welcome</h1>;
}

Static Metadata on Individual Pages

Most content pages only need a title, a description, and a canonical path:

// app/contact/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Contact",
  description:
    "Get in touch with the Web Solution Master team about projects and consulting.",
  alternates: {
    canonical: "/contact",
  },
};

export default function ContactPage() {
  return <h1>Contact</h1>;
}

The rendered head contains:

<title>Contact | Web Solution Master</title>
<meta
  name="description"
  content="Get in touch with the Web Solution Master team about projects and consulting."
/>
<link rel="canonical" href="https://websolutionmaster.com/contact" />

Dynamic Metadata With generateMetadata

Blog posts, product pages, and author profiles need tags built from data. Export an async generateMetadata function. It receives the same params and searchParams as the page, and in Next.js 15 and later both are promises you must await.

Here is a complete blog post route that reads Markdown files, similar to how this site works:

// lib/posts.ts
import { cache } from "react";
import fs from "node:fs/promises";
import path from "node:path";
import matter from "gray-matter";

export type Post = {
  slug: string;
  title: string;
  description: string;
  date: string;
  image?: string;
  author: string;
  content: string;
};

const POSTS_DIR = path.join(process.cwd(), "src/content/blog");

export const getPost = cache(async (slug: string): Promise<Post | null> => {
  try {
    const file = await fs.readFile(path.join(POSTS_DIR, `${slug}.md`), "utf8");
    const { data, content } = matter(file);
    return {
      slug,
      title: data.title,
      description: data.description,
      date: new Date(data.date).toISOString(),
      image: data.image,
      author: data.author,
      content,
    };
  } catch {
    return null;
  }
});

export async function getAllSlugs(): Promise<string[]> {
  const files = await fs.readdir(POSTS_DIR);
  return files
    .filter((f) => f.endsWith(".md") && !f.startsWith("_"))
    .map((f) => f.replace(/\.md$/, ""));
}
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllSlugs, getPost } from "@/lib/posts";

export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({
  params,
}: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return { title: "Post not found", robots: { index: false } };
  }

  const url = `/blog/${post.slug}`;

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: url },
    openGraph: {
      type: "article",
      url,
      title: post.title,
      description: post.description,
      publishedTime: post.date,
      authors: [post.author],
      images: post.image
        ? [{ url: post.image, width: 1920, height: 1080, alt: post.title }]
        : undefined,
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.description,
      images: post.image ? [post.image] : undefined,
    },
  };
}

export default async function BlogPostPage({
  params,
}: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {/* render post.content with your MDX renderer */}
    </article>
  );
}

Things to notice:

  • getPost is wrapped in React's cache, so generateMetadata and the page share one read per request instead of reading the file twice. fetch calls are memoized automatically, but database queries and filesystem reads are not, so wrap them yourself.
  • PageProps<"/blog/[slug]"> is a global type helper that Next.js generates from your routes. It types params as a promise with the right keys. If your editor does not recognize it, run npx next typegen or start the dev server once.
  • openGraph is defined in full. Because metadata merges shallowly (explained below), setting openGraph here replaces the root layout's openGraph object, including siteName. Repeat any shared fields you need, or spread them from a shared module.
  • Because generateStaticParams lists every slug, the metadata for each post is resolved at build time and baked into the static HTML.

Extending parent metadata

generateMetadata receives a second argument, parent, which resolves to the metadata from parent segments. Use it to extend rather than replace inherited values:

// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
import { getProduct } from "@/lib/products";

export async function generateMetadata(
  { params }: PageProps<"/products/[id]">,
  parent: ResolvingMetadata,
): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);
  const previousImages = (await parent).openGraph?.images ?? [];

  return {
    title: product.name,
    description: product.summary,
    openGraph: {
      images: [product.imageUrl, ...previousImages],
    },
  };
}

export default async function ProductPage({
  params,
}: PageProps<"/products/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  return <h1>{product.name}</h1>;
}

Metadata for search and filter pages

Filtered listing pages such as /blog?page=3 or /shop?color=red often create duplicate content. Use searchParams to point them at a clean canonical URL or keep them out of the index:

// app/shop/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  searchParams,
}: PageProps<"/shop">): Promise<Metadata> {
  const { color } = await searchParams;
  const filtered = typeof color === "string" && color.length > 0;

  return {
    title: filtered ? `Shop ${color} products` : "Shop",
    alternates: { canonical: "/shop" },
    robots: filtered ? { index: false, follow: true } : undefined,
  };
}

export default function ShopPage() {
  return <h1>Shop</h1>;
}

Reading searchParams makes the page render at request time, so only do this on pages that are dynamic anyway.

Canonical URLs and hreflang

alternates covers canonical links, language alternates, and feeds:

// app/[lang]/about/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: PageProps<"/[lang]/about">): Promise<Metadata> {
  const { lang } = await params;

  return {
    title: lang === "bn" ? "আমাদের সম্পর্কে" : "About us",
    alternates: {
      canonical: `/${lang}/about`,
      languages: {
        "en-US": "/en/about",
        "bn-BD": "/bn/about",
        "x-default": "/en/about",
      },
      types: {
        "application/rss+xml": "/feed.xml",
      },
    },
  };
}

export default function AboutPage() {
  return <h1>About</h1>;
}

Next.js turns these into link rel="canonical" and link rel="alternate" hreflang="..." tags, with every path resolved against metadataBase. For more on multi-language setups, see how to create a multi-language website with Next.js.

Robots Directives

Use robots to control indexing per page. Search results pages, thank-you pages, and account areas are typical candidates for noindex:

// app/account/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  robots: {
    index: false,
    follow: false,
    googleBot: {
      index: false,
      follow: false,
    },
  },
};

export default function AccountLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <section>{children}</section>;
}

Because this lives in a layout, every page under /account inherits it. For public pages, you can allow large image previews in Google results:

// app/blog/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  robots: {
    index: true,
    follow: true,
    googleBot: {
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
};

export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <>{children}</>;
}

Viewport and Theme Color Are a Separate Export

Since Next.js 14, themeColor, colorScheme, and viewport settings are not part of metadata. They are deprecated there and belong in a separate viewport export:

// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#ffffff" },
    { media: "(prefers-color-scheme: dark)", color: "#0b1120" },
  ],
  colorScheme: "light dark",
};

There is also a generateViewport function for the rare case where viewport values depend on params. If you have old metadata exports with these fields, the metadata-to-viewport-export codemod moves them for you.

How Metadata Merges Across Layouts

Next.js evaluates metadata from the root layout down to the page, then shallowly merges the objects. Later segments win, and a nested object such as openGraph, twitter, or robots is replaced as a whole, not merged key by key.

// app/layout.tsx
export const metadata = {
  title: "Acme",
  openGraph: { siteName: "Acme", description: "Acme builds tools." },
};
// app/about/page.tsx
export const metadata = {
  title: "About",
  openGraph: { title: "About Acme" },
};
// Result: og:title is "About Acme", but og:site_name and og:description are gone

The fix is to keep shared nested fields in one module and spread them wherever you override the object:

// app/shared-metadata.ts
export const baseOpenGraph = {
  siteName: "Web Solution Master",
  locale: "en_US",
  type: "website" as const,
};
// app/about/page.tsx
import type { Metadata } from "next";
import { baseOpenGraph } from "../shared-metadata";

export const metadata: Metadata = {
  title: "About",
  openGraph: {
    ...baseOpenGraph,
    title: "About Web Solution Master",
    url: "/about",
  },
};

export default function AboutPage() {
  return <h1>About</h1>;
}

A page that does not define openGraph at all inherits the parent's object untouched.

Streaming Metadata

For pages rendered at request time, Next.js does not hold back the whole response while generateMetadata waits on a slow database. It streams the visible UI first and injects the resolved metadata tags once they are ready.

Crawlers that execute JavaScript, like Googlebot, read streamed metadata correctly. For HTML-limited bots that do not run JavaScript, such as Facebook's and Slack's link preview crawlers, Next.js detects the user agent and blocks rendering until metadata is resolved, so the tags land in the head. You can customize that bot list, or disable streaming metadata entirely, with htmlLimitedBots:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Treat every user agent as HTML-limited: always block on metadata
  htmlLimitedBots: /.*/,
};

export default nextConfig;

Disabling streaming makes slow pages slower for real users, so only do it if you have a specific crawler that misbehaves. Prerendered pages are not affected, because their metadata is resolved at build time.

Metadata With Cache Components

If you enable cacheComponents in Next.js 16, generateMetadata follows the same rules as components. When it reads uncached data or runtime APIs while the rest of the page is fully prerenderable, Next.js raises an error so you make an explicit choice.

If the metadata depends on external data but not on the request, cache it:

// app/page.tsx
import { cacheLife } from "next/cache";
import { getSiteSettings } from "@/lib/settings";

export async function generateMetadata() {
  "use cache";
  cacheLife("hours");
  const { title, description } = await getSiteSettings();
  return { title, description };
}

export default function HomePage() {
  return <h1>Home</h1>;
}

One detail: a cached generateMetadata must return serializable values, so return metadataBase as a string rather than a URL object if you set it there. For more on how cached and dynamic parts of a page fit together, see what is Partial Prerendering in Next.js.

File-Based Metadata

Some metadata is better expressed as files in the app directory. Files take priority over the metadata object for the same field.

FileGenerates
favicon.ico, icon.png, apple-icon.pngFavicon and touch icon links
opengraph-image.png or .tsxog:image tags for that segment
twitter-image.png or .tsxtwitter:image tags
robots.ts/robots.txt
sitemap.ts/sitemap.xml
manifest.tsWeb app manifest

A typed robots.ts looks like this:

// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/account/", "/api/"],
      },
    ],
    sitemap: "https://websolutionmaster.com/sitemap.xml",
  };
}

Generating per-post social images with opengraph-image.tsx is a topic of its own, covered in how to generate dynamic Open Graph images in Next.js. For sitemaps, see how to add a sitemap to a Next.js website.

Structured Data (JSON-LD)

The Metadata API does not have a field for JSON-LD. The recommended approach is to render a script tag inside the page. Escape < characters so content from your CMS cannot break out of the script tag:

// app/blog/[slug]/json-ld.tsx
import type { Post } from "@/lib/posts";

export function ArticleJsonLd({ post }: { post: Post }) {
  const data = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.description,
    datePublished: post.date,
    author: { "@type": "Person", name: post.author },
    image: post.image
      ? `https://websolutionmaster.com${post.image}`
      : undefined,
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}

Render <ArticleJsonLd post={post} /> inside the blog post page, next to the article content.

Common Problems and Fixes

  • "metadataBase property in metadata export is not set" warning, or relative image URLs. Set metadataBase in the root layout so relative Open Graph and canonical URLs resolve to your domain.
  • Metadata export ignored. The file has "use client" at the top. Move metadata to a Server Component page or layout.
  • Both metadata and generateMetadata exported. Next.js rejects this. Pick one per segment.
  • Title template not applied. The template is defined in the same segment as the title, or there is no default. Templates only affect child segments.
  • og:site_name disappeared on some pages. A child segment set its own openGraph object and replaced the parent's. Spread shared fields from a common module.
  • params.slug is undefined. In Next.js 15 and 16, params is a promise. Use const { slug } = await params.
  • Using next/head in the app directory. It does nothing there. Replace it with the Metadata API.
  • Theme color warning. Move themeColor and colorScheme from metadata to the viewport export.

Metadata API FAQ

No. next/head only works in the Pages Router. In the App Router, export a metadata object or a generateMetadata function from a layout or page, and Next.js renders the head tags for you.

No. The metadata object and generateMetadata are only supported in Server Components. Keep the page as a Server Component that exports metadata and render your interactive Client Component inside it.

Not if you deduplicate it. fetch requests are memoized automatically across generateMetadata, layouts, and pages in the same request. For database queries or file reads, wrap the function in React cache so it runs only once.

You need it whenever you use relative URLs for Open Graph images, canonical links, or alternates. Set it once in the root layout, ideally from an environment variable so preview and production deployments resolve correctly.

Yes. Googlebot executes JavaScript and reads streamed metadata. Bots that only read HTML, such as social link preview crawlers, are detected automatically and receive metadata in the head before the page renders.

Render it as a script tag with type application/ld+json inside the page or layout component. Escape the less-than character in the serialized JSON to prevent script injection.

Conclusion

The Metadata API replaces hand-written head tags with typed, composable exports. Put site-wide defaults, a title template, and metadataBase in the root layout, use static metadata objects for fixed pages, and use generateMetadata with awaited params and a cached data loader for posts, products, and other data-driven routes.

Watch out for the shallow merge of nested objects like openGraph, keep themeColor in the viewport export, use file conventions for icons, robots, sitemaps, and Open Graph images, and render JSON-LD as an escaped script tag. With those pieces in place, every route in your App Router project ships complete, consistent SEO tags without any manual head management. For a broader checklist beyond tags, see what are the best practices for SEO in Next.js websites.

Here are some useful references for going deeper on the Metadata API:

  1. Next.js Docs: Metadata and OG images — an overview of static, generated, and file-based metadata.
  2. Next.js Docs: generateMetadata — every metadata field, merging rules, and streaming behavior.
  3. Next.js Docs: generateViewport — the viewport and themeColor exports.
  4. Next.js Docs: JSON-LD — the recommended way to add structured data.
  5. Google Search Central: Robots meta tag specifications — what each robots directive means to Google.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading