1How Next.js SEO differs

Next.js has its own metadata system. Instead of rendering <SEOHead>in a layout, you use Next.js's generateMetadata function combined with the toolkit's builder functions. Here's what to use where:

WhatWhereHow
Title, description, OG, TwittergenerateMetadata() in each pageUse mergeSEOConfig, buildTitle, buildCanonicalUrl
JSON-LD structured dataPage component bodyUse schema generators + safeJsonLdSerialize
Robots directivesgenerateMetadata()Use noIndex(), buildRobotsDirectives()
!

Do not use <SEOHead> in Next.js App Router pages. Next.js manages <head> through generateMetadata. Use the toolkit's builder functions to generate the metadata values, then return them in Next.js's format.

Next.js App Router

The App Router (Next.js 13+) is the recommended approach.

2Project structure (App Router)

my-nextjs-app/
├── app/
│ ├── layout.tsx # Root layout (Next.js handles <html>)
│ ├── page.tsx # Home page
│ ├── about/
│ │ └── page.tsx # About page
│ ├── blog/
│ │ ├── page.tsx # Blog listing page
│ │ └── [slug]/
│ │ └── page.tsx # Individual blog post
│ ├── products/
│ │ ├── page.tsx # Product listing
│ │ └── [id]/
│ │ └── page.tsx # Product detail
│ └── dashboard/
│ └── page.tsx # Dashboard (noindex)
├── lib/
│ └── seo.ts # Shared SEO config
├── next.config.js
└── package.json

3Shared SEO config

lib/seo.tsTypeScript
import { createSEOConfig } from "react-ssr-seo-toolkit";

export const siteConfig = createSEOConfig({
  titleTemplate: "%s | My Blog",
  description: "A blog about web development and React.",
  openGraph: {
    siteName: "My Blog",
    type: "website",
    locale: "en_US",
  },
  twitter: {
    card: "summary_large_image",
    site: "@myblog",
  },
});

export const SITE_URL = "https://myblog.com";

4Root layout

In Next.js App Router, the root layout provides the HTML shell. You do not need <SEOHead> here because Next.js injects metadata from generateMetadata automatically.

app/layout.tsxTSX
// Next.js root layout — handles <html> and <body>
// Metadata is injected automatically by Next.js
// from each page's generateMetadata() function

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <nav>{/* Shared navigation */}</nav>
        <main>{children}</main>
        <footer>{/* Shared footer */}</footer>
      </body>
    </html>
  );
}
i

Notice there is no <head> tag in the layout. Next.js manages the <head> automatically based on each page's generateMetadata export.

5Static page with metadata

For static pages, use mergeSEOConfig to build your metadata, then map the values to Next.js's Metadata format.

app/about/page.tsxTSX
import {
  mergeSEOConfig,
  buildTitle,
  buildCanonicalUrl,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "@/lib/seo";
import type { Metadata } from "next";

// Build SEO config using the toolkit
const seo = mergeSEOConfig(siteConfig, {
  title: "About Us",
  description: "Learn about our team and mission.",
  canonical: buildCanonicalUrl(SITE_URL, "/about"),
});

// Export Next.js metadata
export const metadata: Metadata = {
  title: buildTitle(seo.title!, seo.titleTemplate),
  description: seo.description,
  alternates: { canonical: seo.canonical },
  openGraph: {
    title: seo.title,
    description: seo.description,
    url: seo.canonical,
    siteName: seo.openGraph?.siteName,
    type: "website",
    locale: seo.openGraph?.locale,
  },
  twitter: {
    card: "summary_large_image",
    site: seo.twitter?.site,
  },
};

// Page component — just content, no <html> or <head>
export default function AboutPage() {
  return (
    <div>
      <h1>About Us</h1>
      <p>Our team and mission...</p>
    </div>
  );
}

6Dynamic page with generateMetadata

For dynamic routes, use generateMetadata as an async function to fetch data and build SEO config. Add JSON-LD in the page component body.

app/blog/[slug]/page.tsxTSX
import {
  mergeSEOConfig,
  buildTitle,
  buildCanonicalUrl,
  createArticleSchema,
  createBreadcrumbSchema,
  safeJsonLdSerialize,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "@/lib/seo";
import type { Metadata } from "next";

async function getPost(slug: string) {
  // Replace with your data fetching logic
  return {
    title: "My Blog Post",
    excerpt: "A short summary of the post.",
    content: "Full post content...",
    author: "Jane Doe",
    date: "2025-06-15",
    image: `${SITE_URL}/images/${slug}.jpg`,
    slug,
  };
}

// Dynamic metadata — runs on the server
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  const url = buildCanonicalUrl(SITE_URL, `/blog/${slug}`);

  const seo = mergeSEOConfig(siteConfig, {
    title: post.title,
    description: post.excerpt,
    canonical: url,
  });

  return {
    title: buildTitle(seo.title!, seo.titleTemplate),
    description: seo.description,
    alternates: { canonical: url },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url,
      type: "article",
      publishedTime: post.date,
      authors: [post.author],
      images: [{ url: post.image }],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
    },
  };
}

// Page component — content + JSON-LD
export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  const url = buildCanonicalUrl(SITE_URL, `/blog/${slug}`);

  const articleSchema = createArticleSchema({
    headline: post.title,
    url,
    datePublished: post.date,
    author: [{ name: post.author }],
    publisher: { name: "My Blog", logo: `${SITE_URL}/logo.png` },
    images: [post.image],
  });

  const breadcrumb = createBreadcrumbSchema([
    { name: "Home", url: SITE_URL },
    { name: "Blog", url: buildCanonicalUrl(SITE_URL, "/blog") },
    { name: post.title, url },
  ]);

  return (
    <article>
      {/* JSON-LD rendered as script tags in the page body */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: safeJsonLdSerialize(articleSchema),
        }}
      />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: safeJsonLdSerialize(breadcrumb),
        }}
      />
      <h1>{post.title}</h1>
      <p>By {post.author} on {post.date}</p>
      <div>{post.content}</div>
    </article>
  );
}
TIP

In Next.js App Router, JSON-LD <script> tags can go anywhere in the page body — they don't need to be in <head>. Google reads them from anywhere in the document.

7Dashboard page (noindex)

For pages that should not appear in search results (dashboards, admin panels), use the robots metadata field.

app/dashboard/page.tsxTSX
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Dashboard",
  robots: {
    index: false,  // Tells search engines not to index this page
    follow: true,
  },
};

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Your private dashboard content.</p>
    </div>
  );
}

8Reusable metadata helper

To reduce repetition across pages, create a helper that converts the toolkit's SEOConfig into Next.js Metadata.

lib/metadata.tsTypeScript
import {
  mergeSEOConfig,
  buildTitle,
  buildCanonicalUrl,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "./seo";
import type { Metadata } from "next";
import type { SEOConfig } from "react-ssr-seo-toolkit";

/**
 * Converts toolkit SEOConfig into Next.js Metadata.
 * Use this in generateMetadata() or as a static export.
 */
export function buildMetadata(
  pageConfig: Partial<SEOConfig>,
  path?: string
): Metadata {
  const seo = mergeSEOConfig(siteConfig, {
    ...pageConfig,
    canonical: path
      ? buildCanonicalUrl(SITE_URL, path)
      : pageConfig.canonical,
  });

  return {
    title: buildTitle(seo.title ?? "", seo.titleTemplate),
    description: seo.description,
    alternates: seo.canonical
      ? { canonical: seo.canonical }
      : undefined,
    openGraph: {
      title: seo.openGraph?.title ?? seo.title,
      description: seo.openGraph?.description ?? seo.description,
      url: seo.canonical,
      siteName: seo.openGraph?.siteName,
      type: (seo.openGraph?.type as "website") ?? "website",
      locale: seo.openGraph?.locale,
    },
    twitter: {
      card: (seo.twitter?.card as "summary_large_image") ?? "summary_large_image",
      site: seo.twitter?.site,
      creator: seo.twitter?.creator,
    },
  };
}

Now any page can generate metadata in one line:

app/about/page.tsx (simplified)TSX
import { buildMetadata } from "@/lib/metadata";

// One line generates all metadata!
export const metadata = buildMetadata({
  title: "About Us",
  description: "Learn about our team.",
}, "/about");

export default function AboutPage() {
  return (
    <div>
      <h1>About Us</h1>
      <p>Our team and mission...</p>
    </div>
  );
}

Next.js Pages Router

If you are using the Pages Router, you can use <SEOHead> via Next.js's <Head> component.

pages/about.tsx (Pages Router)TSX
import Head from "next/head";
import {
  mergeSEOConfig,
  buildCanonicalUrl,
  SEOHead,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "../lib/seo";

export default function AboutPage() {
  const seo = mergeSEOConfig(siteConfig, {
    title: "About Us",
    description: "Learn about our team.",
    canonical: buildCanonicalUrl(SITE_URL, "/about"),
  });

  return (
    <>
      <Head>
        <SEOHead {...seo} />
      </Head>
      <div>
        <h1>About Us</h1>
        <p>Our team and mission...</p>
      </div>
    </>
  );
}

Summary

FeatureApp RouterPages Router
Meta tagsgenerateMetadata() + builder functions<Head><SEOHead /></Head>
JSON-LD<script> in page body + safeJsonLdSerialize<Head><JsonLd /></Head> or in body
Documentapp/layout.tsx (no <SEOHead>)_app.tsx / _document.tsx
Dynamic dataAsync generateMetadatagetServerSideProps / getStaticProps