Vercel Logo

Dynamic Metadata Done Right

Broken metadata kills SEO (Search Engine Optimization) and social sharing previews. Relative URLs fail in generateMetadata because the server doesn't know the domain. Google uses OpenGraph (a metadata protocol used by social platforms for rich link previews) metadata for search ranking and social platforms need it for rich previews.

Outcome

Working metadata with correct canonical URL (the preferred URL for a page, used by search engines to avoid duplicate content), OG tags, Twitter cards, and no server relative fetches. Direct function calls for data access or absolute URLs with environment variables.

Fast Track

  1. Use direct function calls like getBlogPost(slug) instead of API routes in generateMetadata.
  2. If fetching is required, build absolute URLs from process.env.API_URL.
  3. Add comprehensive OpenGraph and Twitter card metadata with images, timestamps, and authors.

Hands-On Exercise 3.2

Requirements:

  1. Implement generateMetadata for blog posts with title, description, keywords, authors.
  2. Add OpenGraph metadata with featured image (1200x630), type, publishedTime.
  3. Add Twitter card metadata with summary_large_image.
  4. Include error handling with fallback metadata when post not found.
  5. Avoid relative URL fetches on server - use direct data access or absolute URLs.

Implementation hints:

  • CRITICAL: fetch('/api/posts/${slug}') fails in generateMetadata - server doesn't know domain.
  • ✅ Preferred: Direct function calls like getBlogPost(slug).
  • ✅ Alternative: Absolute URLs ${process.env.API_URL}/api/posts/${slug}.
  • Set metadataBase in root layout for proper URL resolution.
  • Keep metadata focused on SEO and social sharing essentials.
Cross-App Metadata Patterns

This lesson demonstrates metadata in both apps for different use cases:

  • apps/blog - Content-focused SEO with dynamic per-post metadata (generateMetadata for individual articles)
  • apps/web - Marketing-focused metadata with static site-wide defaults (root layout configuration)

Both patterns use the same Next.js metadata API. The difference is when metadata is personalized (per-route vs site-wide).

Need Help Generating Metadata?

Unsure what metadata fields to include or how to structure your generateMetadata implementation? Use this prompt:

Prompt: Generate generateMetadata Implementation
<context>
I'm implementing dynamic metadata for a Next.js page using the generateMetadata function.
I need to generate SEO-optimized metadata with OpenGraph and Twitter card tags.
My page displays: [describe what your page shows - e.g., blog post, product page, user profile]
</context>
 
<current-implementation>
Show your current page component or data fetching function.
 
Example:
    // Your current code here
    export default async function MyPage({ params }: { params: Promise<{ slug: string }> }) {
      // ...
    }
</current-implementation>
 
<data-available>
What data do you have access to for this page?
- Title: [field name]
- Description: [field name]
- Image: [field name]
- Author: [field name]
- Published date: [field name]
- Tags/categories: [field name]
- [Other relevant fields]
</data-available>
 
<questions>
1. **Required fields:** What metadata is essential for my content type (blog, product, profile)?
2. **Image optimization:** What dimensions should OpenGraph images be? (Recommended: 1200x630)
3. **Fallbacks:** What should I show if data is missing or page not found?
4. **Twitter vs OG:** Do I need both Twitter card and OpenGraph tags, or just OpenGraph?
5. **Dynamic URLs:** Should I include canonical URLs? How do I handle absolute vs relative?
</questions>
 
<specific-scenario>
Example: Blog post with title, excerpt, featured image, author, and publish date
Expected metadata:
- Page title with site suffix
- Description from excerpt
- OpenGraph image 1200x630
- Article type with published time
- Author attribution
- Twitter large image card
</specific-scenario>
 
Generate a complete generateMetadata implementation with proper TypeScript types, error handling with fallbacks, OpenGraph tags, Twitter cards, and direct data access (no relative URLs). Include comments explaining each metadata field.

This will give you production-ready metadata that passes social sharing validators and maximizes SEO!

Try It

  1. View page source: Right-click page, "View Page Source", verify <meta property="og:title">, <meta property="og:image">, <meta name="twitter:card">.
  2. Test with sharing debuggers:
  3. Verify absolute URLs: All image URLs and canonical URLs should be absolute (start with https://).

Commit & Deploy

git add -A
git commit -m "feat(advanced): implement safe dynamic metadata with absolute URLs"
git push -u origin feat/advanced-dynamic-metadata

Done-When

  • Navigate to a blog post page, right-click and View Page Source: find <meta property="og:title"> with post title
  • In page source: find <meta property="og:image"> with absolute URL starting with https://
  • In page source: find <meta name="twitter:card" content="summary_large_image">
  • In page source: find <link rel="canonical"> with absolute URL to current page
  • In page source: find <meta property="og:type" content="article">
  • Open DevTools Console during server build: no "Failed to parse URL" or relative fetch errors
  • Test with Twitter Card Validator: preview shows correct title, description, and image

Solution

Click to reveal solution

Dynamic Per-Post Metadata (apps/blog)

Add generateMetadata to the existing blog post page. The @repo/api/blog package provides fetchPostBySlug:

apps/blog/src/app/[slug]/page.tsx
import { fetchPostBySlug } from '@repo/api/blog'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
 
type Props = {
  params: Promise<{ slug: string }>
}
 
// ✅ Direct data access - preferred approach
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await fetchPostBySlug(slug)
 
  if (!post) {
    return {
      title: 'Post Not Found',
      description: 'The requested blog post could not be found.',
    }
  }
 
  return {
    title: post.title, // Layout template adds " | VAF Blog" suffix
    description: post.excerpt,
    keywords: post.tags.join(', '),
    authors: [{ name: post.author.name }],
    alternates: {
      canonical: `/${slug}`, // metadataBase resolves to absolute URL
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
      type: 'article',
      publishedTime: post.publishedAt.toISOString(),
      authors: [post.author.name],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const post = await fetchPostBySlug(slug)
 
  if (!post) {
    notFound()
  }
 
  return (
    <main className="flex flex-col gap-6">
      <Link href="/" className="text-sm text-blue-600 hover:underline">
        ← Back to posts
      </Link>
 
      <article className="flex flex-col gap-4">
        <header className="flex flex-col gap-2">
          <h1 className="font-bold text-4xl">{post.title}</h1>
          <p className="text-sm text-gray-500">
            {post.category} · {post.readingTime} min read ·{' '}
            {post.publishedAt.toLocaleDateString()}
          </p>
          <p className="text-sm text-gray-500">By {post.author.name}</p>
        </header>
 
        <div className="prose max-w-none">
          {post.content.split('\n\n').map((paragraph, i) => (
            <p key={i} className="mb-4">
              {paragraph}
            </p>
          ))}
        </div>
 
        <footer className="flex flex-wrap gap-2 border-t pt-4">
          {post.tags.map((tag) => (
            <span
              key={tag}
              className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600"
            >
              {tag}
            </span>
          ))}
        </footer>
      </article>
    </main>
  )
}

Static Site-Wide Metadata with metadataBase (apps/blog)

Add metadataBase to the blog layout for proper URL resolution:

apps/blog/src/app/layout.tsx
import type { Metadata } from 'next'
 
import './globals.css'
 
export const metadata: Metadata = {
  metadataBase: new URL(
    process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}`
      : 'http://localhost:3001'
  ),
  title: {
    template: '%s | VAF Blog',
    default: 'Vercel Academy Foundation - Blog',
  },
  description: 'Articles and tutorials from the VAF team',
  openGraph: {
    siteName: 'VAF Blog',
    locale: 'en_US',
    type: 'website',
  },
}
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className="container mx-auto px-4 py-8">{children}</body>
    </html>
  )
}
Critical Anti-Pattern: Relative URLs in generateMetadata

❌ Never do this:

export async function generateMetadata({ params }) {
  // FAILS - server doesn't know domain
  const res = await fetch(`/api/posts/${params.slug}`)
}

✅ Do this instead:

export async function generateMetadata({ params }) {
  // Option 1: Direct function call (preferred)
  const post = await getBlogPost(params.slug)
 
  // Option 2: Absolute URL with env var
  const res = await fetch(`${process.env.API_URL}/api/posts/${params.slug}`)
}

References