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
- Use direct function calls like
getBlogPost(slug)instead of API routes ingenerateMetadata. - If fetching is required, build absolute URLs from
process.env.API_URL. - Add comprehensive OpenGraph and Twitter card metadata with images, timestamps, and authors.
Hands-On Exercise 3.2
Requirements:
- Implement
generateMetadatafor blog posts with title, description, keywords, authors. - Add OpenGraph metadata with featured image (1200x630), type, publishedTime.
- Add Twitter card metadata with
summary_large_image. - Include error handling with fallback metadata when post not found.
- Avoid relative URL fetches on server - use direct data access or absolute URLs.
Implementation hints:
- CRITICAL:
fetch('/api/posts/${slug}')fails ingenerateMetadata- server doesn't know domain. - ✅ Preferred: Direct function calls like
getBlogPost(slug). - ✅ Alternative: Absolute URLs
${process.env.API_URL}/api/posts/${slug}. - Set
metadataBasein root layout for proper URL resolution. - Keep metadata focused on SEO and social sharing essentials.
Try It
- View page source: Right-click page, "View Page Source", verify
<meta property="og:title">,<meta property="og:image">,<meta name="twitter:card">. - Test with sharing debuggers:
- Facebook: https://developers.facebook.com/tools/debug/
- Twitter: https://cards-dev.twitter.com/validator
- LinkedIn: https://www.linkedin.com/post-inspector/
- 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-metadataDone-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 withhttps:// - 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:
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:
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>
)
}References
Was this helpful?