Dynamic Metadata & SEO with generateMetadata

Step 25 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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 — no description or Open Graph tags
  • There's no metadata template to keep titles consistent across pages

In this step, you will:

  1. Add a metadata template in the root layout for consistent titles
  2. Add static metadata to the posts listing page
  3. Enhance the post detail page's generateMetadata with description and Open Graph tags
  4. 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

  1. How Metadata Works in Next.js
  2. Static vs Dynamic Metadata
  3. Adding a Title Template
  4. Adding Metadata to the Posts Page
  5. Enhancing the Post Detail Metadata
  6. Understanding Open Graph Tags
  7. How Metadata Merges Across Layouts
  8. Verify the Metadata
  9. Summary & Key Takeaways

How Metadata Works in Next.js

↑ Index

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

↑ Index

Static (export const metadata)Dynamic (export async function generateMetadata)
When to useTitle/description are fixed (homepage, about page)Title/description depend on data (post detail, user profile)
Can fetch data?NoYes — it's an async function
Receives props?NoYes — params and searchParams
PerformanceFastest — resolved at build timeSlightly 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

↑ Index

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 toBrowser 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

↑ Index

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

↑ Index

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

  1. 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.

  2. openGraph — Tags that social media platforms read when someone shares a link:

    • title — The title shown in the share card
    • description — The description shown in the share card
    • type: "article" — Tells platforms this is an article (not a website, profile, etc.)
    • authors — The author name
    • publishedTime — 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

↑ Index

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:

PlatformUses OG tags?
FacebookYes
LinkedInYes
Twitter/XYes (also supports its own twitter: tags)
SlackYes
DiscordYes
iMessageYes

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

PropertyPurposeExample
og:titleShare card title"My First Post"
og:descriptionShare card description"This is the content..."
og:typeContent type"article", "website"
og:imagePreview image URL"https://yourapp.com/image.jpg"
og:urlCanonical URL"https://yourapp.com/posts/1"
article:authorAuthor name"Alice"
article:published_timePublish date"2026-03-15T00:00:00.000Z"

We are not adding an og:image in 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

↑ Index

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

  1. app/layout.tsx — root metadata
  2. app/posts/layout.tsx — posts section metadata (if it exists)
  3. 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

↑ Index

Check the title template

  1. Visit http://localhost:3000 — the tab should show "NextJs-FullStack-App-Blog-APP" (the default)
  2. Visit /posts — the tab should show "All Posts — NextJs-FullStack-App-Blog-APP"
  3. Visit /posts/1 — the tab should show "[Post Title] — NextJs-FullStack-App-Blog-APP"

Check Open Graph tags

  1. Visit /posts/1 in your browser
  2. Right-click → View Page Source
  3. 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:


Summary & Key Takeaways

↑ Index

ConceptDetails
export const metadataStatic metadata — for pages with fixed titles and descriptions
generateMetadata()Dynamic metadata — async function that fetches data to build metadata
Title templatetemplate: "%s — App Name" in the root layout — automatically appends the app name
default titleUsed when a page doesn't set its own title
descriptionShows as the snippet in search results — keep it under 160 characters
Open Graph tagsControl how links look when shared on social media (Facebook, LinkedIn, Twitter, Slack)
Metadata mergingChild pages override parent metadata; unset fields are inherited
Server-renderedMetadata 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.