SEO for Solos
16.xreact

Next.js App Router SEO

The primary target of this playbook. Metadata is a first-class export, robots and sitemap are file conventions, and static generation is the default for anything that does not read a request.

What you start with

  • +A typed Metadata API with title templates and canonical alternates
  • +File conventions for robots.ts, sitemap.ts, manifest.ts and opengraph-image.tsx
  • +Static generation by default, with generateStaticParams for dynamic segments
  • +next/font, which self-hosts webfonts at build time with zero external requests

What it makes harder

  • !Reading cookies or headers anywhere in a route makes the whole route dynamic, which silently removes it from static generation
  • !A nonce-based CSP requires reading headers in the root layout, which makes every route dynamic
  • !generateStaticParams alone is not a gate: unknown params still render unless dynamicParams is false or the page calls notFound()

Recipes

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

Self-referencing absolute canonical on every page

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

app/pricing/page.tsx
import type { Metadata } from "next";
import { canonicalUrl } from "@/lib/seo";

export const metadata: Metadata = {
  title: "Pricing",
  description: "Three tiers, one-time payment, 30 day refund.",
  alternates: { canonical: canonicalUrl("/pricing") },
};

robots.txt generated from route constants

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

app/robots.ts
import type { MetadataRoute } from "next";
import { robotsRules, RECOMMENDED_POLICY } from "@/kit/seo/robots";
import { SITE_URL } from "@/lib/seo";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: robotsRules({
      ...RECOMMENDED_POLICY,
      disallowPaths: ["/api/", "/library/", "/account/", "/auth/"],
      sitemaps: [`${SITE_URL}/sitemap.xml`],
    }),
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}

Entity gate that 404s unknown slugs

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

app/glossary/[term]/page.tsx
import { notFound } from "next/navigation";
import { GLOSSARY_TERMS } from "@/data/glossary-terms";

// Without this, an unknown param is still rendered on demand.
export const dynamicParams = false;

export function generateStaticParams() {
  return GLOSSARY_TERMS.map((term) => ({ term: term.slug }));
}

export default async function TermPage({ params }: { params: Promise<{ term: string }> }) {
  const { term } = await params;
  const entry = GLOSSARY_TERMS.find((candidate) => candidate.slug === term);
  if (!entry) notFound();
  return <TermTemplate entry={entry} />;
}

Sitemap composed from the content modules

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

app/sitemap.ts
import type { MetadataRoute } from "next";
import { buildSitemap, PRIORITY } from "@/kit/seo/sitemap";
import { getAllSections } from "@/lib/content";
import { SITE_URL } from "@/lib/seo";

export default function sitemap(): MetadataRoute.Sitemap {
  return buildSitemap(
    [
      {
        name: "playbook",
        items: getAllSections(),
        url: (section) => `/playbook/${section.slug}`,
        lastModified: (section) => section.updatedAt,
        priority: PRIORITY.primaryContent,
        changeFrequency: "monthly",
      },
    ],
    { siteUrl: SITE_URL, languages: ["en-US", "x-default"] },
  );
}

Self-hosted fonts with zero external requests

A Google Fonts link costs a DNS lookup, a TLS handshake and a render-blocking request on the critical path, before any character appears. Check perf-fonts-self-hosted

app/layout.tsx
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
  weight: ["400", "500", "600", "700"],
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html className={inter.variable} lang="en-US">
      <body>{children}</body>
    </html>
  );
}

One JSON-LD script holding one graph

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

app/layout.tsx
import { siteGraph } from "@/lib/seo";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const json = JSON.stringify(siteGraph()).replace(/</g, "\\u003c");
  return (
    <html lang="en-US">
      <head>
        <script dangerouslySetInnerHTML={{ __html: json }} type="application/ld+json" />
      </head>
      <body>{children}</body>
    </html>
  );
}

The same checks in other frameworks

Questions

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

Next.js App Router starts with: A typed Metadata API with title templates and canonical alternates; File conventions for robots.ts, sitemap.ts, manifest.ts and opengraph-image.tsx; Static generation by default, with generateStaticParams for dynamic segments; next/font, which self-hosts webfonts at build time with zero external requests.

What does Next.js App Router make harder?

Reading cookies or headers anywhere in a route makes the whole route dynamic, which silently removes it from static generation This is specific to Next.js App Router rather than a general SEO problem.

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

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