Fixing Canonical Mismatches in Next.js App Router
Resolve the “Duplicate, Google chose different canonical” state in a Next.js App Router site by making the canonical tag absolute, server-rendered in generateMetadata, and consistent with your trailing-slash policy.
When to Use This Approach
Apply this fix when you see any of these symptoms:
- Google Search Console reports “Duplicate, Google chose different canonical” for URLs you expect to be indexed on their own address.
- The canonical tag is present when you inspect the page in the browser but absent or relative in the raw production HTML.
- The mismatch appeared after an App Router migration or after moving canonical logic into a client component.
Implementation Steps
Step 1: Audit the Rendered HTML Canonical
Read the canonical from the raw production response, not the browser DevTools DOM, because DevTools shows the post-hydration state that a crawler may never reach.
# What is actually in the first byte of HTML?
curl -s https://example.com/blog/my-post \
| grep -oiE '<link[^>]+rel="canonical"[^>]*>'
Validation: the command must print an absolute href (https://example.com/blog/my-post). An empty result means the tag is client-injected; a relative href means the base URL is missing.
Step 2: Move the Canonical Into generateMetadata
Set alternates.canonical in the route’s server generateMetadata export and delete any useEffect or client component that writes a canonical tag. Server metadata is in the first HTML response; a client effect is not. This is the App Router equivalent of the pattern in canonical URL enforcement.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getCmsEntry } from '@/lib/cms';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
const { slug } = await params;
const entry = await getCmsEntry(slug);
const base = process.env.NEXT_PUBLIC_SITE_URL!;
return {
alternates: { canonical: `${base}/blog/${slug}` },
};
}
Validation: rebuild, then rerun the Step 1 curl and confirm the tag is now present and absolute in the raw HTML.
Step 3: Set NEXT_PUBLIC_SITE_URL in Every Build
A relative or localhost base is the most common cause of a leaked or missing canonical. Set the absolute production origin in every build environment.
# .env.production (and the CI/CD environment)
NEXT_PUBLIC_SITE_URL=https://example.com
Validation: print the variable in a build step (node -e "console.log(process.env.NEXT_PUBLIC_SITE_URL)") and confirm it is the production origin, not undefined or a preview host.
Step 4: Align trailingSlash With the Emitted Canonical
If next.config redirects /blog/my-post to /blog/my-post/ but your canonical omits the slash, the served URL and the declared canonical disagree. Pick one policy and make the canonical builder emit exactly that form. Consistent slugs here depend on your slug normalization strategies.
// next.config.js
module.exports = {
trailingSlash: false, // canonical must also be slash-free
};
Validation: curl -sI https://example.com/blog/my-post/ should 308/301 to the slash-free URL, and that URL’s canonical must match the redirect target exactly.
Step 5: Revalidate and Recheck in GSC
Trigger revalidation so the corrected tag reaches the CDN, then re-run URL Inspection to confirm Google now agrees with your declared canonical.
# Revalidate the affected path, then re-inspect
curl -s -X POST "https://example.com/api/revalidate?path=/blog/my-post&secret=$REVALIDATE_TOKEN"
npx gsc-check --site https://example.com --urls ./url-sample.txt \
--field userCanonical,googleCanonical,verdict
Validation: in the URL Inspection output, userCanonical and googleCanonical should match and verdict should move toward PASS on the next crawl.
SEO Impact Summary
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Canonical selection | Google adopts your declared canonical when it is absolute and server-rendered | A relative or client-only tag lets Google pick a different variant |
| Index consolidation | Ranking signals concentrate on one URL per piece of content | Duplicate variants split link equity and coverage |
| Trailing-slash consistency | The served URL and canonical agree on one slash form | A slash mismatch produces a redirect target that differs from the canonical |
| Environment safety | NEXT_PUBLIC_SITE_URL keeps every build emitting the production origin |
A missing base leaks preview or localhost hosts into the tag |
Measurable signals to watch:
- GSC Page Indexing: “Duplicate, Google chose different canonical” count should fall toward zero within 2–3 crawl cycles.
- URL Inspection:
googleCanonicalshould equaluserCanonicalon rechecked URLs. - Raw-HTML audit: every sampled URL should return one absolute canonical with no client injection.
Edge Cases and Gotchas
Dynamic APIs silently force client rendering
Calling cookies(), headers(), or reading searchParams in the wrong scope can opt a route out of static generation, and if canonical logic then falls to the client the tag disappears from the first response. Keep canonical resolution inside generateMetadata and verify the route stays statically rendered in the build output.
Preview deployments poison the sample
Vercel preview URLs carry a different origin. If NEXT_PUBLIC_SITE_URL is unset on previews, canonicals resolve to the preview host; never audit canonicals on a preview deployment and never let preview URLs into your sitemap.
Query-parameter variants re-open the mismatch
Even with a correct tag, ?utm_source= and ?ref= variants can be crawled and reported as duplicates. Strip tracking parameters at the edge so the canonical form is the only indexable address, coordinating with the framework-level wiring in Next.js App Router SEO configuration.
Metadata templates override the canonical
A title.template or shared metadata in a parent layout can merge unexpectedly with route metadata. Confirm the route-level alternates.canonical is not overwritten by a layout default by checking the final rendered tag, not the source.
Frequently Asked Questions
Why does Google pick a different canonical than mine?
Google treats your canonical as a hint and overrides it when the signal is weak or contradictory: a relative or client-injected tag, internal links pointing at a different variant, or a canonical that does not match the trailing-slash form of the served URL. Make the tag absolute, server-rendered in generateMetadata, and consistent with your internal links to earn selection.
Should canonical be absolute or relative?
Always absolute, including protocol and host. A relative canonical is resolved against whatever origin served the response, so a staging or preview host can leak into the signal. Build the value from NEXT_PUBLIC_SITE_URL so it is identical across every environment.
Does trailingSlash affect canonical?
Yes. If next.config trailingSlash and the canonical string disagree on the slash, Next.js may redirect the served URL to a form that differs from the declared canonical, creating a mismatch. Pick one policy and make the canonical builder emit exactly that form.
How long until Google reflects the fix?
Expect two to three crawl cycles after revalidation for URL Inspection to show googleCanonical matching your tag. You can speed discovery by requesting indexing on a sample in Search Console, but the underlying signal only counts once the corrected HTML is served on every crawl.
Part of: Canonical URL Enforcement
Related
- Canonical URL Enforcement — the full multi-layer approach to declaring and enforcing canonical URLs
- Next.js App Router SEO Configuration — configure metadata, rendering, and sitemap wiring for the App Router
- Slug Normalization Strategies — keep the slug consistent so the canonical builder emits a stable path