Search engine visibility starts with proper metadata. In Next.js, the Metadata API gives us a type-safe way to manage page titles, descriptions, Open Graph tags, and canonical URLs—everything search engines need to index and display our content correctly. By Day 40, you've built pages and components; now we'll ensure they're discoverable and rank well across Google, social platforms, and other crawlers.
Why Metadata Matters in Next.js
Before the Metadata API, we'd manually inject <meta> tags into the DOM or use third-party libraries. That approach was fragile: tags could conflict, get overwritten, or fall out of sync with page content. Next.js 13+ centralizes metadata declaration in layout and page files, making it predictable and type-safe. The framework handles server-side rendering of meta tags, ensuring crawlers see the right information on the first request—critical for SEO.
Every page needs at minimum a title and description. Social platforms (Twitter, LinkedIn, Facebook) parse Open Graph tags to generate rich previews. Search engines use titles, descriptions, and canonical URLs to understand duplicate content and avoid penalizing us for publishing the same article under multiple URLs. Typed metadata means we catch missing or malformed tags at build time, not after deployment.
Static Metadata — Export from Layouts and Pages
For pages with fixed content—marketing pages, about us, contact—we export a metadata object directly from the file. This is the simplest approach and requires no async calls.
// app/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Expona – Secure Tunnels & Edge Networking',
description: 'Build faster with instant tunnels and edge-first deployment.',
openGraph: {
title: 'Expona – Secure Tunnels & Edge Networking',
description: 'Build faster with instant tunnels and edge-first deployment.',
url: 'https://expona.dev',
siteName: 'Expona',
images: [
{
url: 'https://expona.dev/og-home.png',
width: 1200,
height: 630,
alt: 'Expona homepage',
},
],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Expona – Secure Tunnels & Edge Networking',
description: 'Build faster with instant tunnels and edge-first deployment.',
images: ['https://expona.dev/og-home.png'],
},
};
export default function Home() {
return (
<main>
<h1>Welcome to Expona</h1>
</main>
);
}
Export metadata from layouts as well. Child pages inherit and merge with the layout's metadata, so you can set global defaults once:
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://expona.dev'),
title: {
default: 'Expona',
template: '%s | Expona',
},
description: 'Developer infrastructure for secure tunnels and edge networking.',
twitter: {
creator: '@expona_dev',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Notice metadataBase: it sets the root URL for all relative image paths and canonical links. This prevents us from hardcoding the domain in every image tag.
Dynamic Metadata — generateMetadata for Content-Driven Pages
Blog posts, product pages, and user profiles have content that comes from a database. We can't hardcode their metadata. Instead, we export an async generateMetadata function that fetches the content and returns metadata based on it.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPost } from '@/lib/posts';
import { notFound } from 'next/navigation';
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
authors: [{ name: post.author }],
keywords: post.tags,
openGraph: {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author],
images: [
{
url: post.ogImage || post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<div>{post.content}</div>
</article>
);
}
Key points: generateMetadata runs on the server at build time (for static posts) or request time (for dynamic content). It receives the same params as the page component. Fetch your content once, and use it in both generateMetadata and the page component—Next.js deduplicates the requests automatically. If your post doesn't exist, return minimal metadata and let the page return notFound() separately.
Title Templates — Consistent Branding
A title template ensures every page follows your branding pattern without repeating the full suffix. Instead of setting title: 'Blog | Expona', title: 'Docs | Expona', title: 'Pricing | Expona', define the template once:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: 'Expona',
template: '%s | Expona',
},
};
Now, when a child page exports title: 'Blog', Next.js renders <title>Blog | Expona</title> automatically. The template replaces %s with the page's title. This scales across dozens of pages and keeps your site's identity consistent. If a page needs the exact title without the template, use title: { absolute: 'My Exact Title' } to bypass the template.
Canonical URLs — Preventing Duplicate Content Penalties
Canonical URLs tell search engines the authoritative version of a page, critical when the same content is accessible via multiple URLs (e.g., /blog/post, /blog/post/, /blog/post?ref=email). Without canonicals, Google may index all versions and split ranking signals.
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return { title: 'Not Found' };
}
const canonicalUrl = `https://expona.dev/blog/${slug}`;
return {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
alternates: {
canonical: canonicalUrl,
},
openGraph: {
url: canonicalUrl,
// ... other OG tags
},
};
}
Set alternates.canonical to the absolute URL of the primary version. Combine it with metadataBase in your root layout so you only define the domain once. If you have translations or alternate language versions, use alternates.languages:
alternates: {
canonical: 'https://expona.dev/blog/post',
languages: {
es: 'https://expona.dev/es/blog/post',
fr: 'https://expona.dev/fr/blog/post',
},
}
Real-World Scenario: Multi-Tenant SaaS Platform
Imagine we're building a SaaS dashboard where each tenant has their own domain or subdomain, but content is shared in a database. We need metadata to reflect each tenant's branding while serving the same underlying content:
// app/[tenantId]/dashboard/page.tsx
import type { Metadata } from 'next';
import { getTenant } from '@/lib/tenants';
type Props = {
params: Promise<{ tenantId: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tenantId } = await params;
const tenant = await getTenant(tenantId);
if (!tenant) {
return { title: 'Tenant Not Found' };
}
return {
title: {
absolute: `${tenant.name} Dashboard`,
},
description: `Manage your ${tenant.name} account and resources.`,
openGraph: {
title: `${tenant.name} Dashboard`,
description: `Manage your ${tenant.name} account and resources.`,
images: [
{
url: tenant.logoUrl,
width: 200,
height: 200,
alt: `${tenant.name} logo`,
},
],
},
alternates: {
canonical: `https://${tenant.domain}/dashboard`,
},
};
}
export default async function Dashboard({ params }: Props) {
const { tenantId } = await params;
const tenant = await getTenant(tenantId);
return (
<div>
<h1>{tenant.name} Dashboard</h1>
{/* dashboard content */}
</div>
);
}
Now each tenant's dashboard has branded metadata without maintaining separate page files. The canonical URL points to their primary domain, so multi-tenant setups don't cause duplicate content issues.
Common Mistakes
- Forgetting to await params in generateMetadata: In newer versions,
paramsis a Promise. Alwaysawait paramsbefore accessing properties. Skipping this causes runtime errors. - Hardcoding domains in images: Use
metadataBasein your root layout and reference images with relative paths so you can change the domain once without updating every page. - Duplicating generateMetadata logic: If your page component and generateMetadata both fetch the same data, keep them synchronized. Use a shared function or refactor carefully to avoid data inconsistency.
- Ignoring OpenGraph for social sharing: OG tags are parsed by Twitter, LinkedIn, and Facebook. Missing or malformed tags result in poor social previews. Always test with the social platform's sharing debuggers.
- Using templates with absolute titles: If you export
title: { absolute: '...' }, the template is ignored. This is intentional but easy to forget, leading to unexpected output. - Not setting canonical URLs on dynamic pages: Every page should have a canonical, especially if accessible via query params or multiple paths. Without it, you risk search penalties.
Key Takeaways
- Use static
metadataexports for fixed pages andgenerateMetadatafor dynamic, database-driven pages. - Define
metadataBaseandtitle.templatein your root layout to avoid repetition and maintain consistency. - Always include Open Graph and Twitter tags so your content displays richly on social platforms.
- Set canonical URLs on every page to prevent duplicate content penalties and consolidate ranking signals.
- Test metadata output with browser DevTools, Google's Rich Results Test, and social platform sharing debuggers before launching.
- Keep metadata in sync with page content; fetch data once and use it in both
generateMetadataand the page component.






Comments
Loading comments…