SEO and Metadata
SEO in Next.js App Router
Next.js 14 has powerful built-in SEO capabilities through the Metadata API. Let's implement:
- Dynamic page titles and descriptions
- Open Graph tags for social sharing
- Twitter Cards
- JSON-LD structured data
- Sitemap generation
Global Metadata
Set default metadata in your root layout:
// src/app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://your-domain.com'),
title: {
default: 'My Headless Blog',
template: '%s | My Headless Blog',
},
description: 'A modern blog built with headless WordPress and Next.js',
keywords: ['blog', 'wordpress', 'nextjs', 'headless cms'],
authors: [{ name: 'Your Name' }],
creator: 'Your Name',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://your-domain.com',
siteName: 'My Headless Blog',
images: [
{
url: '/og-default.jpg',
width: 1200,
height: 630,
alt: 'My Headless Blog',
},
],
},
twitter: {
card: 'summary_large_image',
creator: '@yourhandle',
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Dynamic Post Metadata
Create a helper function for generating post metadata:
// src/lib/seo.ts
import { Metadata } from 'next';
import { WPPostWithEmbed, getFeaturedImageUrl, getAuthorName } from './types';
export function generatePostMetadata(post: WPPostWithEmbed): Metadata {
const title = post.title.rendered.replace(/<[^>]*>/g, '');
// Clean excerpt for description
const description = post.excerpt.rendered
.replace(/<[^>]*>/g, '')
.replace(/s+/g, ' ')
.trim()
.substring(0, 160);
const featuredImage = getFeaturedImageUrl(post, 'large');
const author = getAuthorName(post);
const publishedTime = new Date(post.date).toISOString();
const modifiedTime = new Date(post.modified).toISOString();
// Get categories for keywords
const categories = post._embedded?.['wp:term']?.[0] ?? [];
const keywords = categories.map(c => c.name);
return {
title,
description,
keywords,
authors: [{ name: author }],
openGraph: {
title,
description,
type: 'article',
publishedTime,
modifiedTime,
authors: [author],
images: featuredImage ? [
{
url: featuredImage,
width: 1200,
height: 630,
alt: title,
},
] : [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: featuredImage ? [featuredImage] : [],
},
};
}Use in your post page:
// src/app/blog/[slug]/page.tsx
import { generatePostMetadata } from '@/lib/seo';
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
if (!post) {
return { title: 'Post Not Found' };
}
return generatePostMetadata(post);
}JSON-LD Structured Data
Add structured data for rich search results:
// src/components/PostSchema.tsx
import { WPPostWithEmbed, getFeaturedImageUrl, getAuthorName } from '@/lib/types';
interface PostSchemaProps {
post: WPPostWithEmbed;
url: string;
}
export function PostSchema({ post, url }: PostSchemaProps) {
const author = getAuthorName(post);
const image = getFeaturedImageUrl(post, 'large');
const schema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title.rendered.replace(/<[^>]*>/g, ''),
description: post.excerpt.rendered.replace(/<[^>]*>/g, '').substring(0, 160),
image: image || undefined,
author: {
'@type': 'Person',
name: author,
},
publisher: {
'@type': 'Organization',
name: 'My Headless Blog',
logo: {
'@type': 'ImageObject',
url: 'https://your-domain.com/logo.png',
},
},
datePublished: post.date,
dateModified: post.modified,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}Add to your post page:
// In single post page
<PostSchema post={post} url={`https://your-domain.com/blog/${post.slug}`} />Generating a Sitemap
Create an automatic sitemap:
// src/app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllPostSlugs, getAllCategorySlugs } from '@/lib/api';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://your-domain.com';
// Static pages
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
];
// Blog posts
const postSlugs = await getAllPostSlugs();
const postPages = postSlugs.map((slug) => ({
url: `${baseUrl}/blog/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
// Categories
const categorySlugs = await getAllCategorySlugs();
const categoryPages = categorySlugs.map((slug) => ({
url: `${baseUrl}/category/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.6,
}));
return [...staticPages, ...postPages, ...categoryPages];
}Robots.txt
// src/app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
},
sitemap: 'https://your-domain.com/sitemap.xml',
};
}Canonical URLs
Add canonical URLs to prevent duplicate content:
// In generateMetadata function
return {
// ... other metadata
alternates: {
canonical: `https://your-domain.com/blog/${post.slug}`,
},
};Testing SEO
Use these tools to verify your implementation:
- Google Rich Results Test – Check structured data
- Facebook Sharing Debugger – Test Open Graph tags
- Twitter Card Validator – Test Twitter Cards
- Lighthouse – Overall SEO audit
Summary
In this lesson, we:
- ✅ Set up global metadata defaults
- ✅ Created dynamic post metadata
- ✅ Added JSON-LD structured data
- ✅ Generated automatic sitemaps
- ✅ Configured robots.txt
Next up: Deploying your headless WordPress site!