Skip to main content
ASoc
Tutorial

Next.js Canonical URLs: 15 Declarations, 472 URLs, None From the Request

A canonical is one line of metadata; deciding what collapses into it is the work. The facet URLs we never created, and two noindex pages that keep theirs.

The ASoc Team12 min read

In the Next.js App Router a canonical URL is one line — alternates: { canonical: "/pricing" } inside a route's exported metadata, resolved to an absolute URL against the root layout's metadataBase. That part takes a minute. Deciding which URLs collapse into it, and which pages get one at all, is the actual work. This build answers that question in 15 places for 472 URLs.

Most guides for this keyword stop at the one line. So here is the whole canonical surface of a real 472-page Next.js site instead: every declaration site, the two pages that deliberately keep a canonical while telling Google not to index them, the facet URLs we chose never to create, and the one defect that taught us canonicals are a string-comparison problem, not a tag problem.

The one line, and the base it resolves against

A static route declares its canonical in the metadata object it exports:

// src/app/pricing/page.tsx
export const metadata: Metadata = {
  title: "Pricing",
  description: "...",
  alternates: { canonical: "/pricing" },
};

A dynamic route builds it from the slug it was generated for:

// src/app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) return { title: "Post not found" };
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${slug}` },
    // ...
  };
}

Both values are root-relative, which only works because the root layout sets a base:

// src/app/layout.tsx
const SITE_URL = siteUrl();

export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  // ...
};

metadataBase is what turns /pricing into https://asoctemplates.com/pricing in the rendered <link rel="canonical">. A relative value needs an absolute base to resolve against, so without metadataBase Next warns at build time and resolves it against a fallback origin instead of your production domain. Set it once, in the root layout, from the same helper everything else reads.

An absolute string in alternates.canonical also works and skips metadataBase entirely. We use relative paths on purpose: there is then exactly one place in the codebase that knows the site's origin, which matters for a reason the last section gets to.

Fifteen declaration sites, 472 URLs

Canonicals are declared 14 times under src/app and once in a shared helper. That covers every prerendered page in the build:

Declaration siteURLs it covers
12 static route files (/, /templates, /pricing, /docs, /blog, /changelog, /license, /terms, /privacy, /refund-policy, /contact, /saved)12
src/app/blog/[slug]/page.tsx → generateMetadata342
src/app/templates/[slug]/page.tsx → generateMetadata111
frameworkLandingMetadata() in src/data/frameworkLanding.ts7

Those four rows are the whole surface, and the build confirms it: grep -rl 'rel="canonical"' .next/server/app --include=*.html matches 472 of the 475 prerendered pages. The three without one are _not-found, _global-error and /forgot-password — which is the right answer for all three, for reasons the noindex section gets to. Run that grep on your own build; it is the only way to know what you actually shipped rather than what you meant to.

The last row of the table is the one worth copying. The seven category hubs (/nextjs-admin-template, /tailwind-shop-template, and five siblings) are separate route files, but none of them declares its own canonical:

// src/data/frameworkLanding.ts
export function frameworkLandingMetadata(
  content: FrameworkLandingContent,
): Metadata {
  const path = `/${content.slug}`;
  return {
    title: content.title,
    description: content.metaDescription,
    alternates: { canonical: path },
    openGraph: {
      type: "website",
      siteName: "ASoc",
      locale: "en_US",
      url: path,
      title: content.title,
      description: content.metaDescription,
    },
  };
}

Each page file is then two lines — requireFrameworkLandingPage("nextjs-admin-template"), then export const metadata = frameworkLandingMetadata(content). The canonical and the og:url are built from the same path constant in the same function, so they cannot disagree — which is exactly the kind of drift that happens when seven near-identical page files each maintain their own head.

None of them is built from the request

grep -rn "headers()" src/app returns nothing. No canonical on this site is derived from the Host header, the incoming path, or anything else the request carries. Every one is built from data the route already knows: a literal, or the slug generateStaticParams generated it for.

That is a deliberate constraint rather than an accident of static rendering. A canonical read from the request is correct in development and wrong in every situation that matters: behind a proxy, on an alternate domain, on a preview deployment, after a redirect. The page's canonical URL is a property of the page, not of how someone happened to ask for it.

The companion rule is dynamicParams:

// src/app/blog/[slug]/page.tsx
export const dynamicParams = false;

With that line, a URL outside generateStaticParams 404s instead of rendering on demand. Without it, /blog/anything-at-all would render a page — and generateMetadata would dutifully hand it a self-referencing canonical, inviting Google to index a URL we never meant to exist. Canonicalisation and route existence are the same question asked twice.

The facet URLs we never created

The textbook canonical problem is faceted navigation: /templates?category=admin&framework=nextjs&status=ready and the few hundred other combinations, each serving a subset of the same page. The textbook fix is to canonicalise them all back to /templates.

This site has none of them to fix. /templates carries a plain self-referencing canonical:

// src/app/templates/page.tsx
alternates: { canonical: "/templates" },

…and the filters never reach the URL, because they live in component state:

// src/components/organisms/TemplatesExplorer.tsx
const [filters, setFilters] = useState<TemplateFilters>({});
const results = filterTemplates(catalog, filters);

Four dropdowns, 111 products, zero searchParams reads in the explorer, zero router.replace calls. The combinatorial URL space was never generated, so there is nothing to collapse.

This is a trade, and it costs something real. A filtered view is not shareable or bookmarkable, and the back button does not step through filter changes. For a catalogue whose filters are a browsing convenience that is a fair price; for a shop where "show me 40–60 inch TVs under $500" is the page someone wants to send to a friend, it is the wrong call — put the state in searchParams, accept the URL space, and canonicalise.

What replaces facet URLs here is a deliberately small, hand-shaped set of landing pages: the seven category hubs, each a real page with its own copy, its own canonical, and a derived product list. Seven indexable URLs that earn their place beats three hundred that all point at one. If you do want the shareable-filter version, the searchParams pattern and its canonical bookkeeping are in filtering products with searchParams.

When a canonical sits on a noindex page

"Never put a canonical on a noindex page" is common advice, and this build breaks it twice on purpose. The rule we actually follow has three cases:

Page kindrobotsCanonical?Why
Indexable (470 URLs)defaultYes, self-referencingThe normal case
/saved and the 1 coming-soon productindex: false, follow: trueYesCrawlable and linked; its outbound links must still carry
/dashboard, /login, /signup, /forgot-password, /reset-passwordindex: false, follow: falseNoNothing to canonicalise — the page is not in the index at all

The middle row is the interesting one. /saved renders a list held in the visitor's localStorage, so every crawler that fetches it sees the same empty shell:

// src/app/saved/page.tsx
export const metadata: Metadata = {
  title: "Saved templates",
  description: "...",
  alternates: { canonical: "/saved" },
  robots: { index: false, follow: true },
};

A coming-soon product page does the same thing for a different reason — placeholder screenshot, no demo, nothing to buy — and flips back automatically the day its status becomes "available".

Both keep follow: true because they still sit in the link graph, and both keep their canonical because follow: true means a crawler will fetch and parse the page. Giving it a self-reference costs one line and removes an ambiguity. The bottom row drops the canonical because follow: false plus a Disallow in robots.ts means no crawler should be reading that head at all; a canonical there would be markup written for nobody.

Canonicals are a string-comparison problem

The canonical tag is only half of a page's identity claim. The other half is every absolute URL the page emits — og:url, the JSON-LD url and image fields, the sitemap entry — and those have to be byte-identical to the canonical or the page is making two different claims about what it is.

That is not theoretical. A trailing slash in one environment variable once put https://host//templates/asoc-admin into our production structured data while Next was emitting https://host/templates/asoc-admin as the canonical. The URL was valid, the page served fine, and nothing failed a click-through test; what broke was every comparison done on the strings. The fix was to give the origin exactly one definition:

// src/lib/siteUrl.ts
export function siteUrl(fallback = "https://asoctemplates.com"): string {
  return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}

The full post-mortem, including why that || is not a ??, is in the trailing-slash write-up. The lesson for canonicals specifically is sharper than "strip the slash". new URL() normalises a doubled slash and a template literal does not — so Next resolved the relative canonical correctly while every hand-built absolute URL came out wrong. The canonical tag looked fine, which is exactly why the mismatch survived so long. One helper that strips the slash before either mechanism sees it is what makes the two agree. The same helper feeds every absolute URL in our JSON-LD blocks.

One more thing lives in alternates, and it is easy to miss because the field is named after the canonical:

// src/app/blog/page.tsx
alternates: {
  canonical: "/blog",
  types: { "application/rss+xml": `${BASE_URL}/blog/feed.xml` },
},

alternates is the whole alternate-representation block, not just the canonical: types for feeds, languages for hreflang, media for a separate mobile host. A single-language site uses one of the four, which is why the other three stay unfamiliar until the day you need one.

Mistakes and how they show up

MistakeSymptomFix
No metadataBaseCanonicals point at *.vercel.app; build-time warningSet it once in the root layout from your origin helper
Canonical built from headers()Wrong host behind a proxy or on a previewBuild it from the route's own slug
Trailing slash in the origin env varhttps://host//path in JSON-LD but not the canonical tagStrip it centrally; test the helper
next/head <link rel="canonical"> in the App RouterTag appears, then a second one from metadataUse alternates.canonical; never both
Canonical pointing at a redirectCrawler follows a 308 to find the real pageCanonicalise to the 200, in its final slash form
Canonical pointing at a noindex pageSignals contradict; Google ignores oneCanonical and noindex on the same page is fine — pointing at one is not
Same canonical on paginated pagesPages 2+ drop out of the indexSelf-reference each page; do not point them at page 1
Cross-domain canonical to a syndication partnerYour copy deindexes in favour of theirsOnly canonicalise across domains when you mean it
dynamicParams left on for a catalogue routeUnknown slugs render and self-canonicaliseexport const dynamicParams = false
Sitemap URLs in one form, canonicals in anotherCrawl budget spent discovering redirectsBuild both from the same helper and registry

Frequently asked questions

Should alternates.canonical be relative or absolute? Either works. Relative plus metadataBase keeps the origin in one place, which is what you want if anything else on the page also emits absolute URLs — structured data, OG tags, a feed link. Absolute is fine on a site where the canonical is the only absolute URL you produce.

Does a self-referencing canonical do anything? Yes, though not what people expect. It does not improve rankings. It removes a decision: without one, Google picks a canonical for you from the URLs it has seen, and it may pick the version with a tracking parameter, the www host, or a trailing slash. A self-reference makes that choice yours. It is one line per route, so there is no reason to skip it.

My canonical tag is right but Google reports a different canonical. Why? The tag is a hint competing with other signals: internal links, redirects, the sitemap, and the content itself. The usual cause is disagreement — the sitemap lists one form, the links use another, or two URLs serve genuinely identical content and Google picks the one with more links. Check the URL Inspection tool's "user-declared" versus "Google-selected" canonical; when they differ, the signals you control are not unanimous. Fixing it means making every URL on your site agree, not editing the tag harder.

Do query parameters need their own canonicals? Only if they produce pages. Tracking parameters (?utm_source=…) hit a page whose canonical already points at the clean URL, which is enough. Parameters that change the content — filters, sorting, pagination — are real pages and need a real decision: self-reference them if they are worth indexing, canonicalise them to the clean URL if they are not, or keep them out of the URL entirely, as /templates does.

Landing-page templates to apply this to

A single-page marketing build is where canonicals get skipped most often, and where the ?ref= and ?utm_source= variants turn up in the index first. Three to start from: ASoc Amplify is a social-media-management SaaS marketing site, ASoc Atelier a designer's portfolio and studio site with a booking funnel, and ASoc Axiom an AI-consultancy landing page with its own FAQ and pricing. Each is a Next.js App Router project, so every page is a route with a metadata export and the one line at the top of this post is all it needs.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates to bring your own framework. The adjacent routing decisions are in Next.js redirects and trailing slashes; the other half of a page's identity claim is its JSON-LD.

Keep reading

Tutorial8 min read

Next.js Charts: The Bill Isn't the Library, It's the Boundary

Every chart library is a client component. What that actually costs, measured here: 42 KB gzipped across twelve routes, from a five-line constant nobody suspected.

Read more
Tutorial7 min read

A Next.js CI Pipeline in 23 Lines, Timed Step by Step

Real per-step timing from a production run — 100 seconds total, cheapest checks first, no separate typecheck step because next build already does one.

Read more