SEO for Solos
14.x and 15.xreact

Next.js Pages Router SEO

Still the majority of production Next.js. There is no Metadata API, no file conventions for robots or sitemap, and no server components, so every recipe differs from the App Router version.

What you start with

  • +getStaticProps and getStaticPaths for static generation
  • +next/head for per-page metadata
  • +API routes, which is where robots.txt and sitemap.xml have to be generated

What it makes harder

  • !next/head does not deduplicate reliably across nested components, so two components can both emit a canonical
  • !There is no built-in robots or sitemap convention: both are API routes or build-time file writes
  • !fallback: 'blocking' in getStaticPaths renders unknown params, which is an ungated route unless the handler returns notFound

Recipes

Each one is real Next.js Pages Router code, not the same snippet with the imports swapped.

Canonical through a single Seo component

A relative canonical resolves differently depending on the requested URL, which is exactly the ambiguity a canonical exists to remove. Check meta-canonical-absolute

components/Seo.tsx
import Head from "next/head";
import { canonicalUrl } from "@/lib/seo";

// One component owns the head. Emitting tags from more than one place is how a
// page ends up with two canonicals, because next/head does not deduplicate
// custom link elements.
export function Seo({ title, description, path }: { title: string; description: string; path: string }) {
  const url = canonicalUrl(path);
  return (
    <Head>
      <title>{`${title} | Acme`}</title>
      <meta content={description} name="description" />
      <link href={url} rel="canonical" />
      <meta content={url} property="og:url" />
      <meta content="summary_large_image" name="twitter:card" />
    </Head>
  );
}

robots.txt as an API route

A hand-maintained robots.txt drifts from the routes it is supposed to describe. Generating it means adding a private route section cannot forget to disallow it. Check crawl-robots-generated

pages/api/robots.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { robotsTxt, RECOMMENDED_POLICY } from "@/kit/seo/robots";
import { SITE_URL } from "@/lib/seo";

export default function handler(_req: NextApiRequest, res: NextApiResponse) {
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
  res.status(200).send(
    robotsTxt({
      ...RECOMMENDED_POLICY,
      disallowPaths: ["/api/", "/account/"],
      sitemaps: [`${SITE_URL}/sitemap.xml`],
    }),
  );
}

Entity gate with getStaticPaths

An ungated dynamic route generates a page for any slug anyone types, including slugs an attacker generates. That is how a site accidentally publishes ten thousand thin pages. Check pseo-entity-gate

pages/glossary/[term].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { GLOSSARY_TERMS } from "@/data/glossary-terms";

export const getStaticPaths: GetStaticPaths = () => ({
  paths: GLOSSARY_TERMS.map((term) => ({ params: { term: term.slug } })),
  // 'blocking' would render unknown slugs on demand. false is the gate.
  fallback: false,
});

export const getStaticProps: GetStaticProps = ({ params }) => {
  const entry = GLOSSARY_TERMS.find((candidate) => candidate.slug === params?.term);
  if (!entry) return { notFound: true };
  return { props: { entry } };
};

Sitemap written at build time

A hardcoded URL list in a sitemap goes stale the first time a route is added. Composing from the content modules makes the sitemap a projection of reality rather than a claim about it. Check crawl-sitemap-generated

scripts/generate-sitemap.mjs
import { writeFileSync } from "node:fs";
import { sitemapXml, buildSitemap, PRIORITY } from "../kit/seo/sitemap.js";
import { getAllSections } from "../lib/content.js";

// Writing the file at build time avoids an API route that recomputes the whole
// sitemap on every crawler request, which on a 2,000 URL site is a real cost.
const entries = buildSitemap(
  [
    {
      name: "playbook",
      items: getAllSections(),
      url: (section) => `/playbook/${section.slug}`,
      lastModified: (section) => section.updatedAt,
      priority: PRIORITY.primaryContent,
      changeFrequency: "monthly",
    },
  ],
  { siteUrl: process.env.SITE_URL, languages: ["en-US", "x-default"] },
);

writeFileSync("public/sitemap.xml", sitemapXml(entries));

JSON-LD from _document

Multiple disconnected script tags describe several unrelated things. One graph describes one page, and lets nodes reference each other instead of repeating themselves. Check schema-single-script

pages/_document.tsx
import Document, { Head, Html, Main, NextScript } from "next/document";
import { siteGraph } from "@/lib/seo";

export default class MyDocument extends Document {
  override render() {
    const json = JSON.stringify(siteGraph()).replace(/</g, "\\u003c");
    return (
      <Html lang="en-US">
        <Head>
          <script dangerouslySetInnerHTML={{ __html: json }} type="application/ld+json" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

The same checks in other frameworks

Questions

What does Next.js Pages Router give you before you write any SEO code?

Next.js Pages Router starts with: getStaticProps and getStaticPaths for static generation; next/head for per-page metadata; API routes, which is where robots.txt and sitemap.xml have to be generated.

What does Next.js Pages Router make harder?

next/head does not deduplicate reliably across nested components, so two components can both emit a canonical This is specific to Next.js Pages Router rather than a general SEO problem.

How many SEO recipes does SEO for Solos publish for Next.js Pages Router?

SEO for Solos publishes 5 Next.js Pages Router recipes, each with real Next.js Pages Router code and a note on what differs from the same check in other frameworks.