01 / CANONICAL URL BUGS

The #1 Next.js indexing bug: root alternates.canonical inheritance

In Next.js App Router, developers often set metadataBase: new URL("https://example.com") and alternates: { canonical: "/" } inside the root app/layout.tsx, assuming Next.js will automatically set each child page’s canonical to its own pathname. It does not.

If a child page (`app/blog/[slug]/page.tsx`) defines title and description in generateMetadata without overriding alternates.canonical, Next.js shallow-merges or inherits the parent layout’s alternates object—or omits the canonical altogether depending on your layout tree. When child pages inherit <link rel="canonical" href="https://example.com/">, you are explicitly instructing Google that every blog post on your site is a duplicate of your homepage.

BEFORE vs. AFTER · NEXT.JS CANONICAL CONFIGURATIONapp/blog/[slug]/page.tsx
// ❌ BEFORE: Missing alternates.canonical in dynamic route metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.summary,
    // Danger: if app/layout.tsx set alternates: { canonical: '/' },
    // this page may emit canonical="https://example.com/"!
  };
}

// ✅ AFTER: Explicit self-referencing canonical + OpenGraph URL
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};
  const url = `/blog/${slug}`;
  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: url },
    openGraph: {
      title: post.title,
      description: post.summary,
      url,
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt ?? post.publishedAt,
    },
  };
}
02 / DYNAMIC ROUTES & SOFT 404S

Preventing soft 404s and client-only rendering traps

When auditing a Markdown or CMS-backed Next.js blog, check two rendering behaviors on every dynamic route:

03 / SITEMAP ALIGNMENT

Why a Next.js sitemap says “Submitted, not indexed”

Next.js makes it easy to generate a sitemap via app/sitemap.ts. However, two subtle mismatches frequently cause Search Console to reject or ignore URLs in /sitemap.xml:

  1. Mismatch with trailingSlash in next.config.ts: If you deploy a static export (`output: "export"`) on Cloudflare Pages or S3 with trailingSlash: true, every page resolves at /blog/post-slug/. If your app/sitemap.ts outputs https://example.com/blog/post-slug (without the trailing slash), every single URL in your sitemap triggers a 308 redirect! Your sitemap and your canonical tags must use the exact same trailing-slash convention.
  2. Using new Date() for every entry’s lastModified: Setting lastModified: new Date() on every post in app/sitemap.ts stamps the current build time on articles you haven’t touched in two years. Always pass the post’s actual updatedAt or Git commit date.
CLEAN NEXT.JS SITEMAP GENERATORapp/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

const ORIGIN = "https://yourdomain.com";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
  const postEntries: MetadataRoute.Sitemap = posts
    .filter((post) => !post.draft && !post.noindex)
    .map((post) => ({
      url: `${ORIGIN}/blog/${post.slug}/`, // Match trailingSlash setting!
      lastModified: new Date(post.updatedAt ?? post.publishedAt),
    }));

  return [
    { url: `${ORIGIN}/`, lastModified: new Date("2026-11-02") },
    ...postEntries,
  ];
}
04 / REDIRECT STATUS CODES

The accidental 307 Temporary Redirect in middleware.ts

In Next.js middleware, calling NextResponse.redirect(new URL("/new-path", request.url)) defaults to HTTP status 307 Temporary Redirect. When Googlebot encounters a 302 or 307 temporary redirect, it assumes the move is short-lived and often keeps the old URL in the index instead of consolidating signals to the destination URL.

Whenever a redirect in middleware.ts is permanent (such as normalizing uppercase paths, stripping legacy prefixes, or redirecting old blog slugs), pass { status: 308 } or { status: 301 } explicitly, or define the redirect in next.config.ts with permanent: true.

05 / CLOUDFLARE PAGES & VERCEL PREVIEWS

Preventing staging and preview domains from competing with production

When you deploy a Next.js or static site to Cloudflare Pages (*.pages.dev) or Vercel (*.vercel.app), every branch and commit generates a publicly reachable preview URL. If Googlebot discovers a link to your project-name.pages.dev subdomain, it can index the preview host alongside your custom domain.

Protect your canonical domain with two layers:

Honest boundaries & code review safety

Always verify diffs before merging: Automated SEO fixes in a Next.js repository should never bypass your TypeScript compiler or build step. In Searchbop, proposed metadata updates, JSON-LD blocks, and Markdown article drafts are presented as file diffs and submitted via a GitHub pull request so your CI pipeline validates every change.

Official documentation: Google Search Technical Essentials.

Research context & topics covered on this page Next.js · P1

Cluster & intent: nextjs (Informational / commercial · P1) · Planned URL: /guides/nextjs-seo-audit/

Topics addressed: Next.js SEO audit, Next.js sitemap not indexed, Next.js canonical URL, Cloudflare Pages SEO, Markdown blog SEO.

Primary documentation source: Google Search Central — Technical Essentials.