Skip to main content
Technology & EngineeringSeo Content304 lines

Next SEO

"Next.js SEO and metadata management: meta tags, Open Graph, Twitter cards, JSON-LD structured data, canonical URLs, robots directives, and sitemap generation using the Metadata API and next-seo."

Quick Summary24 lines
Modern SEO in Next.js revolves around the built-in Metadata API (App Router) and the `next-seo` package (Pages Router). The goal is declarative, type-safe metadata that is colocated with route segments, automatically merged through the layout hierarchy, and rendered as server-side HTML so crawlers see complete meta tags on first load. Every page should have a unique title, description, canonical URL, and structured data. Open Graph and Twitter cards ensure rich previews when links are shared. Sitemaps and robots.txt guide crawlers efficiently.

## Key Points

- Set `metadataBase` in the root layout so relative URLs in `openGraph.images` and `alternates.canonical` resolve correctly.
- Use the `title.template` pattern to keep page titles consistent without repeating the site name.
- Always provide `alternates.canonical` to avoid duplicate content penalties.
- Generate OG images dynamically with `ImageResponse` rather than maintaining static files for every page.
- Place JSON-LD in the page component body (not in `<head>`) — Google parses it from `<body>` just fine, and it simplifies component composition.
- Keep descriptions between 120 and 160 characters.
- Validate structured data with Google's Rich Results Test after deployment.
- Use `generateStaticParams` alongside `generateMetadata` so metadata is generated at build time for static routes.
- Set `changeFrequency` and `priority` in sitemaps to reflect actual update cadence.
- **Client-side only meta tags**: Using `useEffect` or `document.title` means crawlers never see the metadata. Always use server-rendered metadata.
- **Duplicate titles across pages**: Every page needs a unique title and description. The `title.template` pattern helps but each page must still set its own `title` value.
- **Missing `metadataBase`**: Without it, relative OG image URLs break in production, leading to missing previews on social platforms.

## Quick Example

```bash
npm install next-seo
```
skilldb get seo-content-skills/Next SEOFull skill: 304 lines
Paste into your CLAUDE.md or agent config

Next.js SEO & Metadata

Core Philosophy

Modern SEO in Next.js revolves around the built-in Metadata API (App Router) and the next-seo package (Pages Router). The goal is declarative, type-safe metadata that is colocated with route segments, automatically merged through the layout hierarchy, and rendered as server-side HTML so crawlers see complete meta tags on first load. Every page should have a unique title, description, canonical URL, and structured data. Open Graph and Twitter cards ensure rich previews when links are shared. Sitemaps and robots.txt guide crawlers efficiently.

Setup

App Router (Metadata API)

No extra packages needed. Export metadata or generateMetadata from any layout.tsx or page.tsx:

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "My Site",
    template: "%s | My Site",
  },
  description: "A comprehensive Next.js application.",
  openGraph: {
    type: "website",
    locale: "en_US",
    siteName: "My Site",
  },
  twitter: {
    card: "summary_large_image",
    creator: "@handle",
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-video-preview": -1,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
};

Pages Router (next-seo)

npm install next-seo
// pages/_app.tsx
import { DefaultSeo } from "next-seo";
import type { AppProps } from "next/app";

const DEFAULT_SEO = {
  titleTemplate: "%s | My Site",
  defaultTitle: "My Site",
  description: "A comprehensive Next.js application.",
  openGraph: {
    type: "website",
    locale: "en_US",
    site_name: "My Site",
  },
};

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <DefaultSeo {...DEFAULT_SEO} />
      <Component {...pageProps} />
    </>
  );
}

Key Techniques

Dynamic Metadata with generateMetadata

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
import { getPost } from "@/lib/posts";
import { notFound } from "next/navigation";

interface Props {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  const previousImages = (await parent).openGraph?.images || [];

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [
        {
          url: post.ogImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
        ...previousImages,
      ],
    },
    alternates: {
      canonical: `/blog/${slug}`,
    },
  };
}

JSON-LD Structured Data

// components/json-ld.tsx
import React from "react";

interface ArticleJsonLdProps {
  title: string;
  description: string;
  url: string;
  imageUrl: string;
  datePublished: string;
  dateModified: string;
  authorName: string;
}

export function ArticleJsonLd({
  title,
  description,
  url,
  imageUrl,
  datePublished,
  dateModified,
  authorName,
}: ArticleJsonLdProps) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: title,
    description,
    url,
    image: imageUrl,
    datePublished,
    dateModified,
    author: {
      "@type": "Person",
      name: authorName,
    },
    publisher: {
      "@type": "Organization",
      name: "My Site",
      logo: {
        "@type": "ImageObject",
        url: "https://example.com/logo.png",
      },
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

Sitemap Generation

// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();

  const blogEntries = posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  const staticPages = [
    { url: "https://example.com", priority: 1.0 },
    { url: "https://example.com/about", priority: 0.8 },
    { url: "https://example.com/blog", priority: 0.9 },
  ].map((page) => ({
    ...page,
    lastModified: new Date(),
    changeFrequency: "monthly" as const,
  }));

  return [...staticPages, ...blogEntries];
}

Robots.txt

// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/_next/"],
      },
    ],
    sitemap: "https://example.com/sitemap.xml",
  };
}

Open Graph Images (Route Handler)

// app/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";

export const runtime = "edge";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") ?? "My Site";

  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#0f172a",
          color: "white",
          fontSize: 48,
          fontWeight: 700,
          padding: "40px 80px",
        }}
      >
        <div style={{ marginBottom: 24 }}>{title}</div>
        <div style={{ fontSize: 24, color: "#94a3b8" }}>example.com</div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Best Practices

  • Set metadataBase in the root layout so relative URLs in openGraph.images and alternates.canonical resolve correctly.
  • Use the title.template pattern to keep page titles consistent without repeating the site name.
  • Always provide alternates.canonical to avoid duplicate content penalties.
  • Generate OG images dynamically with ImageResponse rather than maintaining static files for every page.
  • Place JSON-LD in the page component body (not in <head>) — Google parses it from <body> just fine, and it simplifies component composition.
  • Keep descriptions between 120 and 160 characters.
  • Validate structured data with Google's Rich Results Test after deployment.
  • Use generateStaticParams alongside generateMetadata so metadata is generated at build time for static routes.
  • Set changeFrequency and priority in sitemaps to reflect actual update cadence.

Anti-Patterns

  • Client-side only meta tags: Using useEffect or document.title means crawlers never see the metadata. Always use server-rendered metadata.
  • Duplicate titles across pages: Every page needs a unique title and description. The title.template pattern helps but each page must still set its own title value.
  • Missing metadataBase: Without it, relative OG image URLs break in production, leading to missing previews on social platforms.
  • Hardcoded production URLs in development: Use environment variables or metadataBase to avoid committing localhost URLs.
  • Blocking metadata fetches: If generateMetadata calls a slow external API, it delays the entire page response. Cache aggressively or use fetch with next.revalidate.
  • Ignoring robots for staging environments: Staging sites without noindex directives get indexed by Google, creating duplicate content issues.
  • Overstuffing keywords in descriptions: Search engines penalize keyword stuffing. Write natural, human-readable descriptions.
  • Forgetting alt text on OG images: Screen readers and some platforms display alt text; omitting it hurts accessibility and can reduce click-through.

Install this skill directly: skilldb add seo-content-skills

Get CLI access →