The Problem
A client e-commerce site running Next.js 16 lost 38 percent of its indexed URLs in two weeks. Search Console's Pages report showed the same culprit on every dropped URL: "Duplicate, Google chose different canonical than user" for some, and "Duplicate without user-selected canonical" for the rest. Traffic from those pages was already down 22 percent week over week by the time the merchant flagged it.
The pages were not duplicates in any meaningful sense. They were product variants, category filter pages, and tag archives that had been indexing fine for months. The only change was the migration from the Pages Router to the App Router and a switch from a hand-rolled <Head> component to generateMetadata. Both reports point to the same underlying issue: Google sees multiple URLs with near-identical content and the site is not telling it which one is the master.
If your indexing dropped after an App Router migration or a metadata refactor, and Search Console is flagging canonicals, this is almost certainly what happened. Here is the diagnosis and the fix.
Why It Happens
The App Router's generateMetadata does not emit a <link rel="canonical"> unless you ask it to. The Pages Router with next-seo or a custom <Head> usually did, often by accident, because every example tutorial included one. After the migration the canonical disappeared, and Google has to guess.
Google's guess is consistent but not always what you want. It collapses near-duplicates into the URL with the most internal links, sometimes choosing a filter URL like /shop?color=blue as canonical for /shop, sometimes picking the bare category for a paginated variant. Either way, the URLs you actually want indexed slip out of the index over a few weekly crawls.
Three patterns I see on real client sites:
- No
canonicalfield ingenerateMetadata. Most common. The function returnstitleanddescriptionbut noalternates.canonical. Google has no signal. - Self-referencing canonicals that include tracking parameters.
next/navigation'suseSearchParamsis sometimes used to build the canonical, which means?utm_source=newsletterends up in the canonical tag. Every email click creates a new "canonical" and Google treats them as separate URLs. - Pagination pages all pointing at page 1. A common bad mitigation: setting canonical to
/blogon every page of/blog/page/2,/page/3, etc. Google deindexes pages 2+ entirely because they declare themselves duplicates of page 1.
The Search Console docs are clear on the rule: a canonical must be a clean, absolute URL that represents the master version of the content. If you give Google nothing, or give it a polluted URL, you get the duplicate reports.
The Fix
Step 1: Add a canonical in every generateMetadata. Put a metadataBase in the root layout so relative canonicals resolve properly:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL( 'https://www.example.com' ),
};
Then in every page-level generateMetadata, return an alternates.canonical value:
// app/shop/[category]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ category: string }> }
): Promise<Metadata> {
const { category } = await params;
return {
title: `${ category } products`,
alternates: {
canonical: `/shop/${ category }`,
},
};
}
Note the canonical does not include the domain — metadataBase handles that. Note also it does not include any query string. That is deliberate.
Step 2: Strip query strings from canonicals. Filter pages, sort orders, and tracking parameters should never appear in canonical URLs. If your route uses searchParams, ignore them when building the canonical:
export async function generateMetadata(
{
params,
searchParams,
}: {
params: Promise<{ category: string }>;
searchParams: Promise<{ [ key: string ]: string | undefined }>;
}
): Promise<Metadata> {
const { category } = await params;
// Intentionally ignore searchParams in the canonical.
return {
alternates: { canonical: `/shop/${ category }` },
};
}
For filter combinations that genuinely have unique content (a faceted-search hub page, say), generate a static route for them and give that route its own canonical. Do not try to canonicalize a thousand query-string variants.
Step 3: Handle pagination correctly. Each paginated page is its own canonical, pointing at itself:
export async function generateMetadata(
{ params }: { params: Promise<{ page: string }> }
): Promise<Metadata> {
const { page } = await params;
const pageNum = parseInt( page, 10 );
const path = pageNum === 1 ? '/blog' : `/blog/page/${ pageNum }`;
return {
alternates: { canonical: path },
robots: pageNum > 1 ? { index: false, follow: true } : undefined,
};
}
rel="prev" and rel="next" are no longer honored by Google, so don't bother. The right pattern in 2026 is: page 1 canonical and indexable, pages 2+ canonical to themselves but noindex, follow so their links still pass equity. Google's official canonical URL guidance covers the rule in detail.
Step 4: Validate the rendered HTML in production. The metadata API silently drops invalid fields, so testing in staging is not enough. Curl the deployed page and grep:
curl -s https://www.example.com/shop/cameras | grep -i 'rel="canonical"'
You should see exactly one <link rel="canonical" href="https://www.example.com/shop/cameras" />. If you see zero, your generateMetadata is not returning the value. If you see more than one, you have a stray <Head> component left over from the migration.
Step 5: Use the URL inspection tool to force a recrawl. Pick five high-value affected URLs and submit them via Search Console's URL Inspection → Request Indexing. This bypasses the normal crawl queue and tells Google to re-evaluate the canonical immediately. The rest of the site catches up over the next two to three weekly crawl cycles.
For a related crawl-budget issue I have written up, see Crawled, currently not indexed in Search Console fix. It pairs well with the canonical fix above.
The Lesson
The App Router gives you fine-grained control over metadata, but it gives you no defaults. If generateMetadata does not emit a canonical, Google has to pick one, and its pick is usually not yours. Add explicit canonicals everywhere, strip query strings, and validate the rendered HTML before declaring victory.
If your indexing dropped after an App Router migration or a recent SEO refactor, this is the kind of technical SEO work I do — see my services for how I run an indexing audit.