What you'll learn
By the end of this you'll know how to set titles and descriptions that actually show up in Google, how to configure Open Graph cards so your links look decent when shared on Slack or LinkedIn, and how to wire up a canonical URL so you don't accidentally compete with yourself in search results. You'll also know the two or three mistakes that trip up everyone when they first switch to the App Router.
Fair warning: metadata in Next.js 15 has some sharp edges if you're used to the old <Head> tag from Pages Router. They're not hard once you see them, but they'll waste your evening if you hit them blind.
Who this is for
- You're building a site with the App Router and want it to rank in Google
- You've heard "metadata" but aren't sure if that means the
<title>tag, the description, Open Graph, or all of the above (it's all of the above) - You just switched from Pages Router and your
next/headtricks stopped working
You can skip this if you already export generateMetadata fluently and you're only here to look up exact field names. The [Next.js metadata reference](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) is honestly faster for that.
What is metadata?
Metadata is information about your page that lives in the <head> — not visible in the browser window, but read by search engines, social platforms, and browser tabs. The big ones:
<title>— the blue link text in Google results and the browser tab label<meta name="description">— the gray snippet under the title in Google- Open Graph tags (
og:title,og:image, etc.) — what Twitter, LinkedIn, and Slack use when someone pastes your URL
Plain English: metadata is the stuff that makes your page findable and shareable without it appearing in the visible page content.
Prerequisites
- You know how the App Router works (layouts, pages, Server Components)
- You have a Next.js project running locally
- Basic TypeScript comfort — nothing beyond reading types
If the App Router is still new to you, the App Router basics guide is worth reading first. It'll save an hour of confusion about why things aren't where you expect them.
Setup from zero
We'll add metadata to a fresh page so you see every piece in context.
Step 1 — Create a test page
Make src/app/seo-test/page.tsx:
export default function SeoTestPage() {
return (
<main>
<h1>SEO test page</h1>
<p>We will add metadata to this.</p>
</main>
);
}
Visit /seo-test. Bare page, no real title in the tab yet (or it inherits from the layout — we'll fix both).
Step 2 — Add static metadata to the page
In the same file, add an export above the component:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "SEO test — Baseline Notes",
description: "A scratch page for testing Next.js metadata.",
};
export default function SeoTestPage() {
return (
<main>
<h1>SEO test page</h1>
<p>We will add metadata to this.</p>
</main>
);
}
Save and check the browser tab. The title changed. That's the whole API for static pages.
Step 3 — Add metadataBase to the root layout
Open src/app/layout.tsx and add metadataBase plus a title template:
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "Baseline Notes",
template: "%s — Baseline Notes",
},
description: "Practical Next.js guides for developers.",
};
The template means any page that exports title: "SEO test" will automatically get "SEO test — Baseline Notes" in the tab and in Google. You stop repeating the site name everywhere.
Core idea / mental model
Next.js metadata flows down the route tree. Whatever you define in the root layout.tsx is the baseline. Child layouts and pages override specific fields. If a page doesn't set a title, it falls back to the layout's title.default.
Mental model: parent sets defaults, children override what they need.
That's why metadataBase belongs in the root layout — it tells Next.js the full domain so relative paths in OG images resolve correctly. And if you forget it, you'll get a console warning and broken social cards every time someone shares a link.
Key terms
- Static metadata —
export const metadata: Metadata = { ... }in a page or layout; runs at build time - Dynamic metadata —
export async function generateMetadata({ params })in a page; runs per request, can fetch data - metadataBase — root URL of your site; makes relative OG image paths resolve correctly
- title template —
{ default, template }object that auto-appends a site name to child titles - canonical URL — the "official" version of a page; prevents duplicate-content issues when the same content lives at multiple paths
- Open Graph — protocol for controlling link preview cards on social platforms
Step-by-step
Dynamic metadata for a blog post
When the title depends on data (a blog post slug, a product name), use generateMetadata. In Next.js 15, params is a Promise:
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.ogImage ?? "/og-default.png"],
},
};
}
generateMetadata runs on the server before the page renders. You can await database calls or fetch from an API inside it, no problem.
Little tip: Next.js deduplicates data fetches automatically. If you call the same getPost function in both generateMetadata and the page component, it runs once, not twice. You don't need to thread results through props to avoid a second round trip.
Canonical URLs
Once metadataBase is set in the root layout, Next.js derives the canonical URL automatically from the current route. You usually don't need to touch it. If you have a redirect situation where multiple paths serve the same content, you can override it explicitly:
export const metadata: Metadata = {
alternates: {
canonical: "/blog/this-specific-post",
},
};
Sitemap and robots
These aren't <head> metadata in the traditional sense, but they're part of the same SEO job. In the App Router, both are TypeScript files:
src/app/sitemap.ts— exports a function returning your page URLs; Next.js converts it to XMLsrc/app/robots.ts— exports your crawling rules; Next.js generates the text file
No library needed, no XML to write by hand. Check the MetadataRoute.Sitemap type in the Next.js docs for the exact return shape.
Working examples
Full static metadata for a hub page:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js tutorials",
description: "Practical Next.js guides from setup to production.",
openGraph: {
title: "Next.js tutorials — Baseline Notes",
description: "Practical Next.js guides from setup to production.",
url: "https://baseline.so/developers/nextjs",
siteName: "Baseline Notes",
images: [
{
url: "/og/nextjs-tutorials.png",
width: 1200,
height: 630,
alt: "Next.js tutorials on Baseline Notes",
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Next.js tutorials — Baseline Notes",
description: "Practical Next.js guides from setup to production.",
images: ["/og/nextjs-tutorials.png"],
},
};
Little tip: twitter.card: "summary_large_image" gives you the wide image preview on X. Without it you get a tiny thumbnail that most people scroll past. Use the large card on any content page.
Patterns
Static metadata is for pages where the title and description don't come from a database — home page, about page, static landing pages. Runs at build time, zero per-request overhead.
generateMetadata is for pages where the title comes from data — blog posts, product pages, user profiles. It's async so you can fetch freely.
Metadata in layout vs. page: defaults, site name, and OG image fallbacks go in the root layout. Post-specific titles and descriptions go in the individual page or generateMetadata.
Common mistakes
Missing metadataBase — OG images won't resolve in social previews. Add it to the root layout before anything else. This one mistake causes the majority of "my link preview is broken" questions.
Using next/head in App Router pages — That's Pages Router. It doesn't work in App Router. Everything goes through the metadata export or generateMetadata. If you see import Head from "next/head" in an App Router file, delete it.
Duplicate title tags — If both a layout and a page export title as a plain string, you can end up with two <title> elements in the HTML. Use title.default with a template in the layout, and a plain title string in pages — the cascade handles the merge correctly.
Descriptions that are too long — Google shows roughly 150–160 characters. Anything longer gets truncated with an ellipsis and you lose control of the snippet. Write descriptions in that range.
No Open Graph description — <meta name="description"> is for Google. Social platforms use og:description. Set both via the description field and openGraph.description — they can be the same text, but they need to be present separately.
Troubleshooting
OG image not showing on Slack or X: First, confirm metadataBase is in the root layout. Then check the image path — it must be absolute or relative with a valid metadataBase to resolve. Social platforms also cache OG data aggressively; use the [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) or [Twitter Card Validator](https://cards-dev.twitter.com/validator) to force a fresh fetch.
Title shows as "undefined": You're using title.template in the layout but the page has no title export. Add a title.default to the layout so there's always a fallback string.
generateMetadata isn't being called: Make sure it's an async function exported directly from the route segment file (your page.tsx), not from a nested component. It only runs at the page level.
Checklist
- [ ]
metadataBaseset in rootlayout.tsx - [ ] Title template (
{ default, template }) configured in root layout - [ ] Every public page has a unique
titleanddescription - [ ]
openGraph.imagespoints to a real 1200×630 image - [ ]
twitter.cardset to"summary_large_image"on content pages - [ ] Dynamic pages use
generateMetadata, not hardcoded strings - [ ] No
import Head from "next/head"in any App Router file - [ ]
src/app/sitemap.tslists all published public URLs
Practice task
Pick one page in your project that's missing a description. Add generateMetadata to it (even if it returns a static object for now). Open Chrome DevTools → Elements, search for <meta name="description". Confirm it's there. Then paste the URL into [opengraph.xyz](https://www.opengraph.xyz/) and check how the card renders. Fix whatever looks broken.
FAQ
Do I need a library for sitemaps?
No. Write src/app/sitemap.ts that exports a function returning an array of URL objects. Next.js generates the XML at build time. The MetadataRoute.Sitemap type in the Next.js docs shows the exact shape.
Should the root layout use static metadata or generateMetadata?
Static metadata. The root layout isn't a dynamic route — you want it building at compile time, not running per request.
What's the difference between title: "My Site" and title: { default: "My Site", template: "%s — My Site" }?
The plain string makes every page under that layout show "My Site" if they don't override it. The object version lets child pages set a short title (like "About") and automatically get " — My Site" appended. Use the object in root layouts so you stop repeating the brand name in every page file.
Do social platforms read <meta name="description">?
No — they read og:description. Set both: description for Google, openGraph.description for social. The Next.js Metadata type makes them separate fields so you can't accidentally miss one.
What to learn next
Once metadata is dialed in, caching is the next thing that affects SEO in a meaningful way. Whether your pages are static or re-rendered on every request affects both crawl speed and time-to-first-byte. The caching and revalidation guide covers exactly that, including the one mistake that silently breaks your build.
After caching: images and fonts. Using next/image and next/font correctly has a real effect on Core Web Vitals scores, which Google does actually factor into rankings.
Related on Baseline
- [Next.js App Router basics](/developers/nextjs/nextjs-app-router-basics)
- [Next.js caching and revalidate](/developers/nextjs/nextjs-caching-and-revalidate)
Takeaways
The App Router metadata API is genuinely clean once you understand the cascade. Defaults live in layouts, specifics live in pages, and generateMetadata handles anything that needs data fetched before you can build the tags.
If you remember only one thing: add metadataBase to your root layout before you do anything else with metadata. Every broken OG preview you've seen on a Next.js site probably started with a missing metadataBase.