
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:
| Approach | Use it for | Where it lives |
|---|---|---|
metadata object | Pages and layouts whose tags do not depend on data | layout.tsx or page.tsx |
generateMetadata function | Tags that depend on params or fetched data | layout.tsx or page.tsx |
| File conventions | Favicons, Open Graph images, robots.txt, sitemap | Special 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
metadataorgenerateMetadata, 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
templaterequires adefault, 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.tsxdoes not apply to the title inapp/blog/page.tsx, because both files belong to the same/blogsegment, but it does apply toapp/blog/[slug]/page.tsxand every other nested page. - A template in
page.tsxdoes 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:
getPostis wrapped in React'scache, sogenerateMetadataand the page share one read per request instead of reading the file twice.fetchcalls 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 typesparamsas a promise with the right keys. If your editor does not recognize it, runnpx next typegenor start the dev server once.openGraphis defined in full. Because metadata merges shallowly (explained below), settingopenGraphhere replaces the root layout'sopenGraphobject, includingsiteName. Repeat any shared fields you need, or spread them from a shared module.- Because
generateStaticParamslists 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.
| File | Generates |
|---|---|
favicon.ico, icon.png, apple-icon.png | Favicon and touch icon links |
opengraph-image.png or .tsx | og:image tags for that segment |
twitter-image.png or .tsx | twitter:image tags |
robots.ts | /robots.txt |
sitemap.ts | /sitemap.xml |
manifest.ts | Web 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
metadataBasein 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
metadataandgenerateMetadataexported. 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_namedisappeared on some pages. A child segment set its ownopenGraphobject and replaced the parent's. Spread shared fields from a common module.params.slugis undefined. In Next.js 15 and 16,paramsis a promise. Useconst { slug } = await params.- Using
next/headin theappdirectory. It does nothing there. Replace it with the Metadata API. - Theme color warning. Move
themeColorandcolorSchemefrommetadatato theviewportexport.
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:
- Next.js Docs: Metadata and OG images — an overview of static, generated, and file-based metadata.
- Next.js Docs: generateMetadata — every metadata field, merging rules, and streaming behavior.
- Next.js Docs: generateViewport — the viewport and themeColor exports.
- Next.js Docs: JSON-LD — the recommended way to add structured data.
- Google Search Central: Robots meta tag specifications — what each robots directive means to Google.


