LESSON 8 ⏱️ 10 min read

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:

  1. Google Rich Results Test – Check structured data
  2. Facebook Sharing Debugger – Test Open Graph tags
  3. Twitter Card Validator – Test Twitter Cards
  4. 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!