Static & Dynamic Metadata
Export a metadata object from any page.tsx or layout.tsx to set titles, descriptions, Open Graph tags, and more — all server-rendered and crawlable.
For routes that need data (e.g. a blog post), export the async generateMetadata() function instead.
The Metadata API
Search engines and social platforms decide whether to show your page — and how
attractive to make it look — based almost entirely on what's in your HTML
<head>. The title, description, Open Graph tags, canonical URL,
robots directives — all of it lives there. Get it right and Google ranks your pages
higher, Twitter shows a rich card, and LinkedIn displays a proper preview. Get it
wrong and your perfectly built app is invisible.
Next.js 13+ introduced the Metadata API — a type-safe, server-rendered
system for controlling every <head> tag from your
page.tsx and layout.tsx files. No more manually writing
<Head> components or juggling meta tags. This lesson covers every
metadata feature you'll use in a real production app.
1. Static Metadata — The Basics
Export a metadata object from any page.tsx or
layout.tsx file. Next.js reads it at build time and injects the
correct <head> tags into the rendered HTML:
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our team, mission, and the story behind our product.",
};
export default function AboutPage() {
return <main>...</main>;
}
This generates:
<title>About Us</title>
<meta name="description" content="Learn about our team, mission, and the story behind our product." />
Simple, clean, and type-safe. The Metadata type from Next.js gives
you autocomplete for every available field and catches typos at compile time.
2. Title Templates — The Root Layout Pattern
Most sites want every page title to follow a consistent format: "Page Name | Site Name". Instead of repeating the site name in every page's metadata, set a title template in the root layout:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
template: "%s | Acme Corp", // %s is replaced by child page titles
default: "Acme Corp", // Used when a page doesn't set its own title
},
description: "Acme Corp builds tools that help teams ship faster.",
};
// app/about/page.tsx
export const metadata: Metadata = {
title: "About Us",
// Renders as: "About Us | Acme Corp"
};
// app/blog/page.tsx
export const metadata: Metadata = {
title: "Blog",
// Renders as: "Blog | Acme Corp"
};
// app/page.tsx (homepage — no title set)
// Renders as: "Acme Corp" (uses the default)
The template and default pattern means you configure
the site name once and every page automatically gets properly formatted titles.
Change the site name in one place and it updates everywhere.
Absolute Titles — Bypassing the Template
// app/landing/page.tsx
export const metadata: Metadata = {
title: {
absolute: "Summer Sale — 50% Off Everything",
},
};
// Renders as: "Summer Sale — 50% Off Everything" (no "| Acme Corp" appended)
3. Open Graph Metadata
Open Graph tags control how your pages look when shared on social media — Facebook, LinkedIn, Slack, iMessage, and most other platforms read them. Without them, shares show a blank preview or pull random content from the page.
// app/blog/my-post/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "How We Scaled to 1 Million Users",
description: "The architectural decisions, tools, and trade-offs that let us grow from 1K to 1M users in 18 months.",
openGraph: {
title: "How We Scaled to 1 Million Users",
description: "The architectural decisions, tools, and trade-offs that let us grow from 1K to 1M users in 18 months.",
url: "https://acmecorp.com/blog/how-we-scaled",
siteName: "Acme Corp Blog",
images: [
{
url: "https://acmecorp.com/og/how-we-scaled.png",
width: 1200,
height: 630,
alt: "Chart showing user growth from 1K to 1M",
},
],
locale: "en_US",
type: "article",
publishedTime: "2025-03-15T09:00:00.000Z",
authors: ["Jane Smith"],
},
twitter: {
card: "summary_large_image",
title: "How We Scaled to 1 Million Users",
description: "The architectural decisions, tools, and trade-offs behind our growth.",
creator: "@acmecorp",
images: ["https://acmecorp.com/og/how-we-scaled.png"],
},
};
Root Layout Open Graph Defaults
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: "%s | Acme Corp",
default: "Acme Corp",
},
description: "Acme Corp builds tools that help teams ship faster.",
openGraph: {
siteName: "Acme Corp",
locale: "en_US",
type: "website",
images: [
{
url: "https://acmecorp.com/og-default.png",
width: 1200,
height: 630,
alt: "Acme Corp",
},
],
},
twitter: {
card: "summary_large_image",
creator: "@acmecorp",
},
};
4. Dynamic Metadata with generateMetadata
For pages with dynamic routes — blog posts, product pages, user profiles — the
metadata needs to reflect the actual content of the page. Export an async
generateMetadata function instead of a static object:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/queries";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "Post Not Found" };
}
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: "/blog/" + slug },
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt?.toISOString(),
authors: [post.author.name],
images: post.coverImage
? [{ url: post.coverImage, width: 1200, height: 630 }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: post.coverImage ? [post.coverImage] : undefined,
},
};
}
export default async function BlogPostPage({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return <article>{post.title}</article>;
}
Deduplicating DB Queries with cache()
// lib/queries.ts
import { cache } from "react";
import { db } from "@/lib/db";
export const getPostBySlug = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } });
});
// Now generateMetadata and the page share one DB query — no double fetch
5. Canonical URLs
A canonical URL tells search engines which version of a page is the "official" one — essential for preventing duplicate content penalties when the same content is accessible at multiple URLs:
// app/layout.tsx — set base URL so relative paths resolve correctly
export const metadata: Metadata = {
metadataBase: new URL("https://acmecorp.com"),
};
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const { slug } = await params;
return {
alternates: {
canonical: "/blog/" + slug,
// Resolved to: https://acmecorp.com/blog/my-post-slug
},
};
}
6. Robots Meta Tag
Control how search engine crawlers interact with specific pages:
// app/admin/page.tsx — don't index the admin panel
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
// app/thank-you/page.tsx — don't index confirmation pages
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
// app/blog/[slug]/page.tsx — fully indexable with rich snippet settings
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
7. Additional Metadata Fields
Keywords, Authors, Creator
export const metadata: Metadata = {
keywords: ["Next.js", "React", "TypeScript", "web development"],
authors: [{ name: "Jane Smith", url: "https://janesmith.dev" }],
creator: "Acme Corp",
publisher: "Acme Corp",
};
Viewport and Theme Color
// app/layout.tsx — export viewport separately from metadata in Next.js 14+
export const viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0f172a" },
],
width: "device-width",
initialScale: 1,
};
Verification Tags
export const metadata: Metadata = {
verification: {
google: "your-google-search-console-code",
yandex: "your-yandex-code",
other: { "msvalidate.01": "your-bing-code" },
},
};
Icons and Favicon
export const metadata: Metadata = {
icons: {
icon: [
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }],
},
manifest: "/site.webmanifest",
};
Shortcut: placing icon.png, apple-icon.png, or
favicon.ico directly in app/ auto-generates
all icon metadata — no manual config needed.
8. Metadata Inheritance and Merging
Metadata merges as it travels down the route tree. A child page inherits any
fields the parent set that it doesn't override — with one important exception:
openGraph.images arrays replace rather than merge:
// Root layout sets: openGraph.images = ["/og-default.png"]
// Blog post sets: openGraph.images = ["/og/my-post.png"]
// Result: openGraph.images = ["/og/my-post.png"] ← replacement, not merge
If you want a fallback image when a post has no cover image, handle it
explicitly in generateMetadata — don't rely on inheritance:
images: post.coverImage
? [{ url: post.coverImage, width: 1200, height: 630 }]
: [{ url: "/og-default.png", width: 1200, height: 630 }],
9. Common Gotchas
-
Not setting
metadataBase. Without it, relative URLs in canonical links and OG image paths can't be resolved. Next.js warns about this in development. Set it to your production domain in the root layout. -
Exporting metadata from a Client Component. The
metadataexport andgenerateMetadataonly work in Server Components. If your page needs to be a Client Component, move the metadata export to a parent layout. -
Double DB queries in
generateMetadataand the page. Wrap shared queries in React'scache()to deduplicate — otherwise you hit the database twice for the same record. -
Wrong OG image dimensions. The standard is 1200×630px.
Always declare
widthandheightin the images array so platforms can size the preview correctly without downloading the image first. -
Forgetting
og:type: "article"for blog posts. Setting the correct OG type enables richer previews on some platforms and signals to Google that the page is an article, which can improve rich results.
Key Takeaways
- Export a
metadataobject from anypage.tsxorlayout.tsx— fully server-rendered, SEO-friendly, and type-safe. - Use
title.templatein the root layout to automatically format every page title as "Page | Site Name". - Use
generateMetadata()for dynamic routes — it's async, queries the DB, and deduplicates with the page viacache(). - Always set
metadataBasein the root layout to your production domain. - Open Graph and Twitter metadata control social share previews — always include a 1200×630px OG image.
- Child metadata merges with parent — except
openGraph.images, which replaces. - Use the
robotsfield to prevent indexing of admin pages, confirmation pages, and duplicate content.
Next up: Lesson 402 — Open Graph & Twitter Card Images. You'll go beyond static
image URLs and learn how to generate dynamic, data-driven OG images at the edge
using Next.js's built-in ImageResponse API — unique images for every
blog post, product, and user profile, generated automatically at request time.