Dynamic Metadata & SEO with generateMetadata
Step 25 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
Our app already has some metadata — the homepage has a static title and description, and the post detail page has a basic generateMetadata that sets the title. But there's more we can do:
- The posts listing page has no metadata at all
- The post detail page only sets the
title— nodescriptionor Open Graph tags - There's no metadata template to keep titles consistent across pages
In this step, you will:
- Add a metadata template in the root layout for consistent titles
- Add static metadata to the posts listing page
- Enhance the post detail page's
generateMetadatawith description and Open Graph tags - Understand how the Metadata API works — static vs dynamic, merging, and templates
Goal: Learn the Next.js Metadata API for SEO — how to set page titles, descriptions, and Open Graph tags both statically and dynamically.
Table of Contents
- How Metadata Works in Next.js
- Static vs Dynamic Metadata
- Adding a Title Template
- Adding Metadata to the Posts Page
- Enhancing the Post Detail Metadata
- Understanding Open Graph Tags
- How Metadata Merges Across Layouts
- Verify the Metadata
- Summary & Key Takeaways
How Metadata Works in Next.js
Next.js provides two ways to define metadata for a page:
1. Static metadata — export a metadata object
// Good for pages where metadata is known at build time
export const metadata: Metadata = {
title: "My Page",
description: "A description of my page",
};
2. Dynamic metadata — export a generateMetadata function
// Good for pages where metadata depends on data (e.g., a post title from the database)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.post.findUnique({ where: { id: Number(id) } });
return {
title: post?.title ?? "Post Not Found",
description: post?.content?.slice(0, 160) ?? "",
};
}
Both produce the same output — <title>, <meta>, and <link> tags in the <head>. The difference is when the values are determined.
Next.js evaluates metadata on the server, before streaming HTML to the client. This means:
- Search engines see the correct
<title>and<meta>tags - Social media platforms (Twitter, Facebook, LinkedIn) can read Open Graph tags
- No client-side JavaScript is needed for SEO
Static vs Dynamic Metadata
Static (export const metadata) | Dynamic (export async function generateMetadata) | |
|---|---|---|
| When to use | Title/description are fixed (homepage, about page) | Title/description depend on data (post detail, user profile) |
| Can fetch data? | No | Yes — it's an async function |
| Receives props? | No | Yes — params and searchParams |
| Performance | Fastest — resolved at build time | Slightly slower — needs data fetching |
Rule of thumb: Use export const metadata when you know the values ahead of time. Use generateMetadata when you need to look something up.
Adding a Title Template
Right now, each page sets its full title independently. This leads to inconsistency — some pages include the app name, some don't. A title template solves this.
Update app/layout.tsx to add a template:
export const metadata: Metadata = {
title: {
default: "NextJs-FullStack-App-Blog-APP",
template: "%s — NextJs-FullStack-App-Blog-APP",
},
description:
"A full-stack blog built with Next.js, Prisma, and NextAuth.js",
};
How the template works
Page sets title to | Browser tab shows |
|---|---|
| (nothing) | NextJs-FullStack-App-Blog-APP (uses default) |
"All Posts" | All Posts — NextJs-FullStack-App-Blog-APP |
"My First Post" | My First Post — NextJs-FullStack-App-Blog-APP |
The %s is a placeholder — Next.js replaces it with the title from child pages. The default is used when a page doesn't set its own title.
Update the post detail page
Since the template now appends the app name, update generateMetadata in app/posts/[id]/page.tsx to not repeat it:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.post.findUnique({
where: { id: Number(id) },
});
return {
title: post ? post.title : "Post Not Found",
};
}
Previously this returned ${post.title} — NextJs-FullStack-App-Blog-APP. Now it just returns post.title, and the template adds the suffix automatically.
Adding Metadata to the Posts Page
The posts listing page (app/posts/page.tsx) currently has no metadata. Add static metadata:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "All Posts",
description:
"Browse all blog posts. A fullstack blog built with Next.js, Prisma, and PostgreSQL.",
};
With the template from the layout, the browser tab will show: "All Posts — NextJs-FullStack-App-Blog-APP"
Enhancing the Post Detail Metadata
The post detail page already has generateMetadata but only sets the title. Let's add description and Open Graph tags.
Update generateMetadata in app/posts/[id]/page.tsx:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true },
});
if (!post) {
return { title: "Post Not Found" };
}
const description = post.content
? post.content.slice(0, 160)
: "Read this post on NextJs-FullStack-App-Blog-APP";
return {
title: post.title,
description,
openGraph: {
title: post.title,
description,
type: "article",
authors: post.author?.name ? [post.author.name] : undefined,
publishedTime: post.createdAt.toISOString(),
},
};
}
What this adds
-
description— The first 160 characters of the post content. Search engines typically show ~155-160 characters in search results. This appears as the snippet below the title in Google. -
openGraph— Tags that social media platforms read when someone shares a link:title— The title shown in the share carddescription— The description shown in the share cardtype: "article"— Tells platforms this is an article (not a website, profile, etc.)authors— The author namepublishedTime— When the article was published (ISO 8601 format)
What Next.js generates in the HTML
<head>
<title>My First Post — NextJs-FullStack-App-Blog-APP</title>
<meta name="description" content="This is the content of my first post..." />
<meta property="og:title" content="My First Post" />
<meta property="og:description" content="This is the content of my first post..." />
<meta property="og:type" content="article" />
<meta property="article:author" content="Alice" />
<meta property="article:published_time" content="2026-03-15T00:00:00.000Z" />
</head>
Understanding Open Graph Tags
Open Graph (OG) tags were created by Facebook to control how links appear when shared on social media. They are now used by almost every platform:
| Platform | Uses OG tags? |
|---|---|
| Yes | |
| Yes | |
| Twitter/X | Yes (also supports its own twitter: tags) |
| Slack | Yes |
| Discord | Yes |
| iMessage | Yes |
Without OG tags
When someone shares yourapp.com/posts/1, the platform shows the URL and maybe scrapes the <title> — but the preview looks plain and uninviting.
With OG tags
The platform shows a rich preview card with:
- A title
- A description
- The author name
- An image (if you add
openGraph.images)
This leads to significantly more clicks when your content is shared on social media.
Common Open Graph properties
| Property | Purpose | Example |
|---|---|---|
og:title | Share card title | "My First Post" |
og:description | Share card description | "This is the content..." |
og:type | Content type | "article", "website" |
og:image | Preview image URL | "https://yourapp.com/image.jpg" |
og:url | Canonical URL | "https://yourapp.com/posts/1" |
article:author | Author name | "Alice" |
article:published_time | Publish date | "2026-03-15T00:00:00.000Z" |
We are not adding an
og:imagein this step because our posts don't have images yet. In Step 29, when we add image support, we could extend the Open Graph metadata with post images.
How Metadata Merges Across Layouts
Next.js merges metadata from parent layouts into child pages. The child's metadata overrides the parent's for any field it sets, and inherits from the parent for fields it doesn't set.
app/layout.tsx → title.template, description
app/posts/page.tsx → title: "All Posts" (inherits description from layout)
app/posts/[id]/page.tsx → title, description, openGraph (overrides from layout)
The merge order
app/layout.tsx— root metadataapp/posts/layout.tsx— posts section metadata (if it exists)app/posts/[id]/page.tsx— page-specific metadata
Each level can add, override, or extend metadata from the previous level. This is why the template defined in app/layout.tsx works for all child pages — they inherit the template and just provide their specific title value.
Verify the Metadata
Check the title template
- Visit
http://localhost:3000— the tab should show "NextJs-FullStack-App-Blog-APP" (the default) - Visit
/posts— the tab should show "All Posts — NextJs-FullStack-App-Blog-APP" - Visit
/posts/1— the tab should show "[Post Title] — NextJs-FullStack-App-Blog-APP"
Check Open Graph tags
- Visit
/posts/1in your browser - Right-click → View Page Source
- Search for
og:— you should see the Open Graph meta tags in the<head>
Test social sharing preview
Use these free tools to preview how your links will look when shared:
- Facebook Sharing Debugger — paste your deployed URL
- LinkedIn Post Inspector — paste your deployed URL
- Twitter/X — paste a link in the compose box to see the preview card
Summary & Key Takeaways
| Concept | Details |
|---|---|
export const metadata | Static metadata — for pages with fixed titles and descriptions |
generateMetadata() | Dynamic metadata — async function that fetches data to build metadata |
| Title template | template: "%s — App Name" in the root layout — automatically appends the app name |
default title | Used when a page doesn't set its own title |
description | Shows as the snippet in search results — keep it under 160 characters |
| Open Graph tags | Control how links look when shared on social media (Facebook, LinkedIn, Twitter, Slack) |
| Metadata merging | Child pages override parent metadata; unset fields are inherited |
| Server-rendered | Metadata is generated on the server — search engines and social platforms see it without JavaScript |
What is Next
In Step 26, we will add search and filtering to the posts page — letting users search posts by title using URL search params and server-side Prisma queries.