How to Add Metadata in Next.js 16 (2026 Beginner's Guide)
You've built your Next.js app, it looks great, it works great — and then you share the link on Twitter or Slack and it shows up as a bare, ugly URL with no title, no image, nothing. Or worse, Google indexes your page with a title that just says "Home."
That's what happens when metadata is an afterthought. The good news: Next.js 16's App Router has a built-in, type-safe way to handle all of this — no next/head, no manual <meta> tag juggling. In this guide, you'll learn exactly how metadata works in the App Router, how to set it per-page, how to make it dynamic for things like blog posts, and the mistakes that trip up almost everyone the first time.
Why Metadata Matters
Metadata is the stuff that doesn't show up on your page, but controls how it's represented everywhere else:
- The tab title and description in search results
- The preview card when someone shares your link on X, LinkedIn, or Discord
- Whether search engines are even allowed to index the page
Skip it, and your app might work perfectly for people already on it, while being invisible or unappealing to everyone who hasn't found it yet.
The Old Way vs the App Router Way
If you've used the Pages Router before, you probably reached for next/head and manually wrote <meta> tags inside a <Head> component. The App Router replaces this entirely with a Metadata API — you export a plain object (or a function) from your page or layout file, and Next.js generates all the right tags for you at build or request time, including merging metadata from parent layouts.
💡 Tip: You never import anything for basic metadata — no
next/head, no<Head>component. Just exportmetadata(orgenerateMetadata) from the file.
Step 1: Static Metadata on a Page
For a page with fixed content — an About page, a Contact page — export a metadata object directly:
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn more about our team and what we build.",
};
export default function AboutPage() {
return <h1>About Us</h1>;
}
What's happening here: Next.js scans your app directory at build time, finds this exported metadata object, and injects the corresponding <title> and <meta name="description"> tags into the <head> automatically. No manual DOM manipulation, no client-side JavaScript needed.
Step 2: Metadata in the Root Layout (Site-Wide Defaults)
Your root layout.tsx is the place to set defaults that apply across the whole site — things like your site name and a fallback description.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "DevStacked",
template: "%s | DevStacked",
},
description: "Practical Next.js, React, and Supabase tutorials.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Understanding title.template: this is one of the most useful (and most missed) features. Once you set a template in the root layout, any page that sets its own title gets automatically wrapped in that pattern.
So if a blog post page sets:
export const metadata: Metadata = {
title: "Stripe Checkout Guide",
};
The actual rendered <title> becomes "Stripe Checkout Guide | DevStacked" — you never have to type "| DevStacked" on every single page.
⚠️ Common Mistake: Setting a full title like
"Stripe Checkout Guide | DevStacked"on every page manually. If you've set atemplatein the root layout, this doubles up or looks inconsistent. Let the template do the work — just set the page-specific part.
What Does metadataBase Do?
Notice the metadataBase field above. Any relative URL you use elsewhere in your metadata — an openGraph.images path, a canonical URL — gets resolved against this base automatically. Without it, Next.js falls back to http://localhost:3000 in development and can log a warning in production, or worse, ship a broken relative URL to a social platform that needs an absolute one.
export const metadata: Metadata = {
metadataBase: new URL("https://devstacked.tech"),
openGraph: {
images: ["/og/cover.png"], // resolves to https://devstacked.tech/og/cover.png
},
};
Set it once, in the root layout, and every nested page and layout can use short relative paths instead of repeating the full domain everywhere.
Metadata Inheritance
Metadata in the App Router isn't isolated per file — it's merged down the layout tree. A field set in app/layout.tsx applies to every page unless a more specific layout.tsx or page.tsx overrides it. So app/blog/layout.tsx could add a shared openGraph.siteName, and each app/blog/[slug]/page.tsx only needs to set the fields that actually change per post, like title and description.
The merge is shallow per key, not a deep object merge — if a page sets its own openGraph object, that whole object replaces the parent's openGraph, it doesn't merge field-by-field inside it. Keep that in mind if a nested page's Open Graph tags seem to be missing fields you set higher up.
Example:
// layout.tsx
openGraph: {
siteName: "DevStacked",
images: [...]
}
// page.tsx
openGraph: {
title: "Stripe Guide"
}
Result? People often expect:
siteName
+
images
+
title
But actually
openGraph
↓
entire object replaced
Step 3: Dynamic Metadata with generateMetadata
Static objects work fine when you already know the content. But what about a blog post page at /blog/[slug] — the title and description depend on which post is being viewed, and that's only known at request time. For this, Next.js gives you generateMetadata, an async function that runs before the page renders.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/posts";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) {
notFound();
}
return {
title: post.title,
description: post.description,
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = getPostBySlug(slug);
return <article>{/* ... */}</article>;
}
Why this matters: generateMetadata runs on the server, separately from your page component, and Next.js waits for it to resolve before streaming the <head> to the browser — so search engines and social crawlers always see the correct, post-specific title and description, not a generic fallback.
⚠️ Common Mistake: Forgetting
paramsis aPromisein Next.js 16. Just like inside the page component itself, you mustawait paramsbefore readingslug— reading it directly (params.slug) will throw a type error or returnundefined.
Step 4: Open Graph Tags (Rich Link Previews)
Open Graph tags control how your link looks when shared on LinkedIn, Slack, Discord, or Facebook — the title, description, and preview image shown in the card.
export const metadata: Metadata = {
title: "Stripe Checkout Guide",
description: "Learn how to integrate Stripe Checkout with Next.js 16.",
openGraph: {
title: "Stripe Checkout Guide",
description: "Learn how to integrate Stripe Checkout with Next.js 16.",
url: "https://example.com/blog/stripe-checkout-guide",
siteName: "DevStacked",
images: [
{
url: "https://example.com/og/stripe-checkout.png",
width: 1200,
height: 630,
alt: "Stripe Checkout Guide",
},
],
type: "article",
},
};
Why a separate openGraph object instead of reusing title/description? Because you often want slightly different copy for a social preview card versus a search result snippet — a social card benefits from being punchier, while a search description often needs to match user search intent more literally. Next.js lets you diverge, but falls back to the top-level title/description if you don't specify openGraph values.
💡 Tip: The recommended Open Graph image size is 1200×630px. Anything smaller gets stretched and looks blurry on most platforms.
Step 5: Twitter Card Tags
X (formerly Twitter) can technically fall back to your Open Graph tags, but adding explicit Twitter Card metadata gives you more reliable control:
export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
title: "Stripe Checkout Guide",
description: "Learn how to integrate Stripe Checkout with Next.js 16.",
images: ["https://example.com/og/stripe-checkout.png"],
},
};
card: "summary_large_image" is what you want almost every time — it renders a large, prominent image above the title and description, instead of a small square thumbnail next to the text.
Step 6: Generating Open Graph Images Dynamically
Instead of designing a static OG image for every single blog post, Next.js lets you generate one on the fly using next/og. Drop a special file named opengraph-image.tsx right next to your page.tsx:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "Blog Post Preview";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const title = slug.replace(/-/g, " ");
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "white",
fontSize: 64,
fontWeight: 700,
textTransform: "capitalize",
}}
>
{title}
</div>
),
{ ...size }
);
}
What's happening here: Next.js automatically detects this file, renders the JSX into a PNG using the Edge Runtime, and serves it at a route matching the page — then automatically wires it up as the og:image for that page. No manual linking required.
💡 Tip:
runtime = "edge"matters here — image generation is CPU-light but benefits from edge deployment for fast, globally distributed rendering.
Step 7: Canonical URLs and Robots
Two smaller but important fields:
export const metadata: Metadata = {
alternates: {
canonical: "https://example.com/blog/stripe-checkout-guide",
},
robots: {
index: true,
follow: true,
},
};
Canonical URLs tell search engines "this is the one true version of this page" — useful if the same content is reachable through multiple URLs (with/without trailing slash, query params, etc.), so you don't get penalized for duplicate content.
💡 Tip: Next.js already auto-generates a canonical URL for a page based on its route and your
metadataBase, so in many straightforward cases you don't have to setalternates.canonicalat all. Setting a custom value matters when you have genuine duplicate content — the same product reachable at two different URLs, paginated pages that should all canonicalize back to page 1, or a query-string variant of a page that shouldn't be indexed separately.
Robots controls whether search engines should index the page at all. Set index: false for things like admin pages, thank-you pages, or draft content you don't want showing up in search results.
Step 8: File-Based Metadata
Not all metadata comes from a metadata object or generateMetadata function. Next.js also supports file-based metadata — special file names inside your app folder that Next.js automatically detects and turns into the correct tags, no export required:
| File | What it generates |
|---|---|
favicon.ico | The browser tab icon |
icon.tsx / icon.png | App icons (including dynamically generated ones via next/og) |
apple-icon.png | Icon used on iOS home screens |
opengraph-image.tsx / .png | The og:image tag, as shown in Step 6 |
twitter-image.tsx / .png | The Twitter Card image, if different from Open Graph |
sitemap.ts | Your sitemap.xml |
robots.ts | Your robots.txt |
Just like opengraph-image.tsx, you can drop any of these next to a page.tsx to scope them to that route, or in the root app folder for site-wide defaults. Next.js wires up the correct <link> or <meta> tag automatically — you never manually reference these files anywhere else in your code.
Frequently Asked Questions
Do I need generateMetadata for every page?
No — only for pages where the metadata depends on data you don't know until request time (a slug, a database lookup, etc.). Static pages like About or Contact should just export a plain metadata object; it's simpler and doesn't need to run on every request.
What happens if I set metadata in both the layout and the page?
Next.js merges them, with the page's metadata taking priority for overlapping fields. Non-overlapping fields (like a description only set in the layout) still apply. This is what lets title.template work — the layout sets the pattern, the page sets the specific title.
Can I use generateMetadata and generateStaticParams together?
Yes, and you should for statically generated dynamic routes. generateStaticParams tells Next.js which slugs exist at build time; generateMetadata runs for each of those and produces the right metadata per page — both commonly pull from the same data source.
Why isn't my Open Graph image showing up when I share the link?
Most likely the image URL isn't absolute, or the platform cached an old preview. Always use a full https:// URL in openGraph.images, and if you're testing, use each platform's own debugger tool (like Facebook's Sharing Debugger) to force a re-scrape.
Does metadata affect Core Web Vitals or performance?
No — metadata is just <head> tags, not render-blocking scripts or styles. generateMetadata running on the server also doesn't block your page's own data fetching, since Next.js can run them in parallel.
Why isn't Google showing my description?
Google often rewrites meta descriptions if it thinks another page excerpt better matches the user's search query.
What's the ideal title length?
Aim for roughly 50–60 characters (or about 580–600 pixels) so titles are less likely to be truncated in search results.
Wrapping Up
Next.js 16's Metadata API takes what used to be manual, error-prone <head> management and turns it into a small, typed export at the top of your file. Static pages get a plain object, dynamic pages get generateMetadata, and layouts let you set sensible defaults once instead of repeating them everywhere.
From here, a natural next step is wiring up JSON-LD structured data for rich search results, or building out per-post dynamic OG images across your whole blog if you haven't already.
💡 Save time: Use our Meta Tag Generator to instantly generate a complete Metadata object, Open Graph tags, Twitter Cards, and JSON-LD for any page — fill in a page's title, description, URL, and image once, and it outputs both raw HTML
<meta>tags and a ready-to-paste Next.js Metadata object.