Two starting points: bare Nuxt and the Nuxt SEO modules
Nuxt, the Vue framework formerly called Nuxt.js, puts no canonical tag in the page on its own: according to the Nuxt SEO and meta docs, its only default head tags are charset and viewport. Install the Nuxt SEO module family, either @nuxtjs/seo or nuxt-seo-utils alone, and a canonical is generated automatically from the configured site URL and the current route.
Which starting point applies changes the job entirely. On bare Nuxt, you write the tag and are responsible for everything it contains. With the modules, the tag already exists and the work is auditing the defaults it was built with. Either way, the tag is a hint to Google, weighed with redirects, HTTPS, and sitemaps; it records a preference rather than settling the question.
Head tags in Nuxt are managed by Unhead through the useHead composable. Where those tags end up, in the HTML the server sends or only in the browser after hydration, depends on the rendering mode, and that is the first thing to settle.
Rendering mode decides whether crawlers see the tag
In Nuxt's default universal rendering, and on prerendered routes, the canonical is part of the first HTML response. With ssr: false (single-page application mode), head tags are added only when JavaScript runs in the browser. Nuxt issue #22506 reported useHead and useSeoMeta tags missing for crawlers in exactly this setup; it was closed as not planned.
That does not make a JavaScript-set canonical invisible to Google. Google queues pages for rendering and says a canonical can be set with JavaScript, with one firm condition: in its JavaScript SEO guidance, Google warns against using script to change the canonical to a different URL than the one in the original HTML, and says an injected canonical must be the only one on the page. The costs of SPA mode are delay and reach. Nuxt's own rendering docs note that client-rendered content takes longer to index, and bots that do not execute JavaScript never see the tag.
For content sites, the practical answer is to keep universal rendering or prerendering for anything indexable and to switch SSR off only where search does not matter, through routeRules:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/admin/**': { ssr: false },
},
})Writing the tag yourself with useHead
Without the modules, the canonical is a link entry passed to useHead in the page component. Build it from an origin you configure, never from the incoming request:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: { siteUrl: 'https://example.com' },
},
})
<script setup lang="ts">
// pages/products/[slug].vue
const config = useRuntimeConfig()
const route = useRoute()
useHead({
link: [{ rel: 'canonical', href: `${config.public.siteUrl}${route.path}` }],
})
</script>
Three details carry the weight here.
route.path, notroute.fullPath. The full path brings the query string and hash with it, which turns every tracking link into its own self-declared canonical.siteUrlis your name, not Nuxt's. It is a key you declare; Nuxt has no built-in by that name. Runtime config only lets an environment variable override a key that already exists innuxt.config, so declaring it is what makesNUXT_PUBLIC_SITE_URLwork in production.- The href must be absolute.
href: route.pathis valid under the RFC, but Google asks for absolute URLs.
A frequent false start is reaching for useSeoMeta. Its documented parameters are meta tags (title, description, Open Graph, Twitter); a canonical is a <link>, which belongs in useHead. No Nuxt page states that exclusion outright, but nothing in useSeoMeta's documented keys produces a link element.
How nuxt-seo-utils builds the automatic canonical
@nuxtjs/seo is a bundle of modules (robots, sitemap, link checker, Open Graph images, Schema.org, site config, and SEO utils), installed with npx nuxt module add @nuxtjs/seo. The canonical specifically comes from nuxt-seo-utils, which the Nuxt modules directory still lists under its old slug, seo-experiments. It composes the tag from three inputs: the site URL, the current route, and a query-parameter allowlist. It also copies the result into og:url.
The site URL is owned by nuxt-site-config:
// nuxt.config.ts
export default defineNuxtConfig({
site: { url: 'https://example.com' },
})
or the NUXT_SITE_URL environment variable. On Vercel, Netlify, and Cloudflare Pages that value can be populated automatically from the CI environment, which is convenient until a preview deployment ends up as the canonical host. Set NUXT_SITE_URL explicitly for production.
The module's defaults are registered at low priority, so a useHead canonical in a page component replaces the automatic one rather than adding a second. That is the intended way to override a single page. To switch all the automatic defaults off, seo: { automaticDefaults: false } does it in one line.
Module defaults that need a decision
The automatic tag is only as good as three settings in the nuxt-seo-utils config. None is wrong in the abstract; each is a guess about your site.
| Option | Default | What to consider |
|---|---|---|
seo.canonicalQueryWhitelist | ['page', 'sort', 'filter', 'search', 'q', 'category', 'tag'] | Keeps these parameters in the canonical, so /shoes?sort=price declares itself preferred. Most catalogs want sorted and filtered views folded into the base URL; keep page if paginated pages are distinct and trim the rest. |
seo.canonicalLowercase | true | Lowercases the canonical. Harmless on lowercase routes, wrong where a path contains a case-sensitive ID and the lowercase URL returns a 404 or other content. |
seo.redirectToCanonicalSiteUrl | false | When on, 301-redirects any request on another host to the site URL, outside development. A server or CDN host redirect often does the same job more cleanly. |
The whitelist is the default most likely to cause a real indexing problem, because its effect is invisible on the pages people usually test: the canonical on a clean URL looks perfect.
Trailing slashes and case: the settings that live in different places
Nuxt spreads URL shape across layers that do not read each other's settings. By default, Vue Router's strict option is false, so /about and /about/ match the same route, and sensitive is also false, so case variants match too. Both can be changed in app/router.options.ts, the approach Nuxt recommends.
The Nuxt SEO side has its own switch. Setting site: { trailingSlash: true } changes the canonical and sitemap URLs, yet NuxtLink needs experimental.defaults.nuxtLink.trailingSlash: 'append' separately, and redirects between the two forms are yours to add through a routeRules redirect or server middleware. Change one layer without the others and both slash variants return 200 while internal links contradict the canonical. Whether the module's canonical respects the router's strict setting is not documented.
nuxt-link-checker helps on the linking side, with rules such as trailing-slash, no-uppercase-chars, and no-double-slashes. It inspects links, not the canonical tag itself, so it complements a canonical check rather than replacing one.
Checking what actually ships
The test that matters is the server HTML, not the DOM in DevTools:
curl -s https://example.com/page | grep -i canonical
Compare that with the Elements panel after hydration. The two must name the same URL; if the rendered DOM differs from the source, JavaScript is changing the canonical, which Google's guidance rules out. Then work through the variants: /page/, /Page, /page?sort=x, and /page?utm_source=x, noting both the status code and the canonical for each.
One risk is worth testing even though the documentation does not settle it. Nuxt site config lists the request URL as a runtime source for the site URL, and it is not clear whether that can override an explicitly configured site.url. Send a request with a forged Host header and confirm the canonical does not change. In Search Console, URL Inspection shows the user-declared and Google-selected canonical side by side, and a live test shows the HTML Google renders, which is the quickest way to confirm an SSR canonical is being read.
Leave well enough alone where the output is right. A site on universal rendering, with NUXT_SITE_URL set and a trimmed whitelist, has a sound canonical; stacking a layout-level tag on top of the module's only adds a duplicate.
Frequently asked questions
Does Nuxt add canonical tags automatically?
Not in core; Nuxt's only default head tags are charset and viewport. With @nuxtjs/seo or nuxt-seo-utils installed, a canonical is generated automatically from site.url and the current route. Review the module's query-parameter and lowercasing defaults before relying on it.
How do I set a canonical URL in Nuxt 3?
Call useHead({ link: [{ rel: 'canonical', href }] }) in the page component, building href from a configured site URL plus route.path. The same composable works in Nuxt 4. Use an absolute URL and avoid route.fullPath, which includes the query string.
Can useSeoMeta set a canonical link in Nuxt?
Its documented parameters are meta tags such as title, description, and Open Graph fields, and a canonical is a link element. Use useHead for the canonical and keep useSeoMeta for meta tags.
Will Google see a canonical in a Nuxt SPA with ssr: false?
Google can render JavaScript and read a canonical added in the browser, but it has to wait for rendering, and bots that do not run JavaScript never see it. Keep universal rendering or prerendering on indexable routes, and switch SSR off only for sections such as admin areas.
Why does my Nuxt canonical include ?sort= or other parameters?
nuxt-seo-utils keeps parameters listed in canonicalQueryWhitelist, and its default includes sort, filter, search, q, category, tag, and page. Trim the list in the seo config to the parameters that genuinely produce distinct content.
How do I override the automatic canonical on one Nuxt page?
Set a canonical with useHead in that page component. The module registers its defaults at low priority, so the page-level tag takes precedence instead of producing a second canonical. Confirm the result with view source.