Why a hardcoded canonical in `app/layout.tsx` de-indexes your blog and product pages
When developers migrate to the Next.js App Router (`app/`), they often configure a global `metadata` object in `app/layout.tsx` that includes `alternates: { canonical: '/' }` or `canonical: 'https://example.com'`. If a child route such as `app/blog/[slug]/page.tsx` overrides `title` and `description` inside `generateMetadata` but forgets to override `alternates.canonical`, the child page can inherit or omit the canonical in unexpected ways.
When every blog post renders <link rel="canonical" href="https://example.com" /> in its HTML <head>, Googlebot obeys your instruction literally: it marks all of your articles as Alternate page with proper canonical tag pointing to the homepage, and removes them from search results.
Copy-paste pattern: `metadataBase` + dynamic `generateMetadata`
To guarantee that every static and dynamic route outputs an exact, absolute, self-referencing canonical tag, pair a clean `metadataBase` in `app/layout.tsx` with explicit `alternates.canonical` in every dynamic route’s `generateMetadata`:
import type { Metadata } from 'next';
const SITE_URL = 'https://yourdomain.com'; // Always use your final canonical production host
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: 'Your Brand — Technical Guides',
template: '%s · Your Brand',
},
robots: {
index: true,
follow: true,
},
};
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/posts';
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
// Match your trailingSlash setting in next.config.mjs!
const canonicalPath = `/blog/${slug}/`;
return {
title: post.title,
description: post.description,
alternates: {
canonical: canonicalPath, // Resolves against metadataBase → https://yourdomain.com/blog/slug/
},
openGraph: {
title: post.title,
description: post.description,
url: canonicalPath,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
},
};
}
Why your Next.js `app/sitemap.ts` shows “Sitemap could not be read” or “Page with redirect”
Google treats `https://yourdomain.com/pricing` and `https://yourdomain.com/pricing/` as two distinct URLs. In Next.js:
- If `trailingSlash: true` is enabled in `next.config.mjs`, Next.js automatically issues an HTTP `308 Permanent Redirect` from `/pricing` to `/pricing/`.
- If `trailingSlash` is `false` (the default), Next.js issues an HTTP `308 Permanent Redirect` from `/pricing/` to `/pricing`.
If your `app/sitemap.ts` file outputs URLs without a trailing slash while `trailingSlash: true` is active (or vice versa), every single URL in your XML sitemap is a redirecting URL. Google Search Console will flag those URLs under Page with redirect or leave them unindexed.
import type { MetadataRoute } from 'next';
import { getAllPublishedPosts } from '@/lib/posts';
const SITE_URL = 'https://yourdomain.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPublishedPosts();
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
// Keep trailing slash identical to generateMetadata and next.config.mjs
url: `${SITE_URL}/blog/${post.slug}/`,
lastModified: new Date(post.updatedAt || post.publishedAt),
changeFrequency: 'monthly',
priority: 0.7,
}));
return [
{
url: `${SITE_URL}/`,
lastModified: new Date('2026-11-01'),
changeFrequency: 'weekly',
priority: 1.0,
},
...postEntries,
];
}
Preventing `VERCEL_URL`, `*.pages.dev`, or `localhost:3000` from polluting canonical tags
Never set `metadataBase: new URL(process.env.VERCEL_URL)` without distinguishing production from preview deployments, and never let `process.env.NEXT_PUBLIC_SITE_URL` fall back to `http://localhost:3000` during a production build. If a build runs with a missing environment variable or uses a branch deployment hostname, Next.js bakes <link rel="canonical" href="http://localhost:3000/blog/post" /> into your static HTML cache—causing Google to drop those pages from the index until the next clean build.
| Symptom in Google Search Console | Exact Next.js App Router Cause | Code Fix |
|---|---|---|
| Alternate page with proper canonical tag | Child routes inherit root `canonical: '/'` from `app/layout.tsx`. | Set explicit `alternates: { canonical: ... }` in each route’s `generateMetadata`. |
| Page with redirect on sitemap URLs | `app/sitemap.ts` trailing slashes disagree with `next.config.mjs` (`308` redirect). | Normalize all sitemap <loc> and canonical URLs to use the exact same slash rule. |
| Submitted URL marked ‘noindex’ | Preview branch check (`VERCEL_ENV !== 'production'`) evaluated true on production build. | Verify live <meta name="robots"> in production HTML source via `curl -s`. |
| Soft 404 on missing dynamic slugs | Dynamic route catches missing data and renders a `200 OK` "Post not found" component. | Call `notFound()` from `next/navigation` inside the Server Component so Next.js returns HTTP `404`. |
Verification commands & official sources
Always verify against built production HTML: Next.js metadata behavior in `next build && next start` can differ from `next dev`. Before deploying, run `curl -s https://yourdomain.com/blog/your-slug/ | grep -E "canonical|robots"` to confirm the rendered <head> tags match your `sitemap.xml` <loc> entries character-for-character. Reference: Next.js Docs — generateMetadata and Google Search Central — Consolidate Duplicate URLs.
Research context & search queries covered on this page Next.js · P1
Cluster & intent: nextjs (Informational / Commercial · P1) · Target queries: Next.js canonical URL, Next.js sitemap not indexed, Next.js SEO audit.
Benchmark prompt addressed: P15 — How do I audit indexing, metadata and canonicals on a Next.js site?