Canonical tags in Lovable: the rules and the traps
Google's canonical rules, then the SPA traps that ruin a Lovable canonical tag: one baked URL on every route, trailing-slash forks, and lovable.app vs your domain.
11 min read
A canonical tag tells Google which URL is the real one when several URLs serve the same content. The rules are short: an absolute URL, inside <head>, self-referencing on the page you want indexed, never a fragment. Breaking one of them in a Lovable project is easy, and the breakage is invisible in the browser.
That last part is why this goes unnoticed for months. Canonicals live in the response HTML, where you can’t see them on screen, and when they go wrong Google quietly consolidates your pages into one.
The short version
- Use absolute URLs, such as
https://acme.com/pricing. - Self-referencing. The page you want indexed declares its own URL.
- In
<head>. A canonical in<body>is ignored. - One per page, one technique. Don’t let an HTTP
Linkheader and a<link>tag disagree. - No fragments.
https://acme.com/#/pricingis not a canonical URL, and a hash route can never be one. - Never one shared canonical for every route. That is the dominant bug in single-page apps and it is worse than shipping no canonical at all.
Everything below is either a rule from Google’s duplicate-URL consolidation docs or a failure mode specific to how Lovable projects get deployed. If canonicals are one item on a longer list for you, start with the full Lovable SEO guide and come back here.
What Google actually asks for
Four things, all from Google’s own page:
- Use absolute paths. Google says to prefer absolute over relative URLs in the
rel="canonical"link element. Relative paths technically work, but they resolve against whatever host served the page, so your staging deploy and your preview URL happily self-canonicalize into the index. - Self-reference the canonical page. Google recommends adding the same self-referential
rel="canonical"to the canonical page itself, as well as to the duplicates. - Keep it in
<head>. Nowhere else. - Don’t send mixed signals. Google warns against specifying different URLs as canonical for the same page using different canonicalization techniques: an HTTP
Linkheader saying one thing while the tag says another.
Two things Google tells you not to do are just as useful: don’t use robots.txt for canonicalization, and don’t use noindex to steer canonical selection within a site.
First, work out which stack your project is on
This changes what you’re debugging. On 13 May 2026 Lovable switched its default stack from React + Vite (client-rendered) to TanStack Start with SSR on Cloudflare Workers. Existing projects were not migrated.
- New projects (13 May 2026 onward) server-render their markup, so a canonical declared in the route’s
head:ships in the response body for everyone. SSR only includes data that a route loader fetched, though. A canonical injected from auseEffect, or assembled from a value the component fetches after mount, is still missing from the HTML: React’suseEffectdocs are explicit that effects “only run on the client. They don’t run during server rendering.” What decides it is where the tag is declared. - Older React + Vite projects are client-rendered, with Lovable applying on-request pre-rendering on deployed public URLs: served only to verified crawlers (Google, Bing, social bots, AI engines). Humans, and third-party scanners, get the SPA shell.
Check package.json rather than guessing:
grep -E '"(@tanstack/react-start|@tanstack/react-router|react-router-dom)"' package.json
@tanstack/react-start means SSR. Only react-router-dom puts you on the pre-rendering-for-verified-crawlers path, which also means your Screaming Frog or Ahrefs audit will report “missing canonical” on pages where Googlebot sees one. That disagreement is expected. Curl with a Googlebot user agent won’t settle it either. Lovable’s docs say pre-rendered HTML goes only to crawlers it verifies, and what verified crawlers receive can’t be verified from outside. Use Search Console’s URL Inspection on that stack.
Trap 1: one canonical, shipped on every route
Here’s the default state of a hand-built Vite SPA that someone told to “add a canonical tag”:
<!-- index.html: served for /, /pricing, /blog/anything, everything -->
<link rel="canonical" href="https://acme.com/" />
Every URL on the site now declares the homepage as its canonical. You have asked Google to consolidate the entire site into one page. That is strictly worse than no canonical, because absent one Google would at least try to work it out from content and links.
The same mechanism produces the duplicate-titles problem, which is why the two almost always appear together: one static <head> in index.html, served unchanged for every route. The fix is the same fix: per-route head management, plus deleting the competing static tags from index.html. That’s covered in per-route meta tags and titles on Lovable.
What the raw response should contain, per route:
<head>
<meta charset="utf-8" />
<title>Pricing | Acme</title>
<meta name="description" content="Simple per-seat pricing. No setup fees." />
<link rel="canonical" href="https://acme.com/pricing" />
</head>
Trap 2: lovable.app or your custom domain
This is the one people get wrong, and it is the most expensive to get wrong because it splits every ranking signal you have across two hosts.
The rule: once a custom domain is the version you want in search results, every canonical on the site points at the custom domain. That includes pages served from the *.lovable.app subdomain. If https://acme.com/pricing is the page you want indexed, then https://acme.com/pricing is what both hosts declare as canonical.
Getting this backwards (leaving a canonical that points at the old subdomain) asks Google to index the subdomain and treat your real domain as a duplicate of it. A relative canonical (href="/pricing") produces a subtler version of the same failure: served from the subdomain it resolves to the subdomain, so each host self-canonicalizes and neither consolidates.
# What does the old subdomain do now?
curl -sI https://yourapp.lovable.app/pricing | grep -iE '^(HTTP/|location:)'
301 (or 308) to the custom domain is what you want. 200 means the duplicate is still live. 302 means you’re telling Google to hang on to the subdomain.
As of September 2026, Lovable’s docs describe custom domains in terms of search visibility but don’t document the status code the old subdomain returns once a domain is attached, so measure rather than assume. If the subdomain still serves 200s and you can’t change that, its canonical must point at the custom domain. That’s the consolidation signal you have left.
Two more things while you’re here. Add both hosts as Search Console properties during the transition, so you can watch the indexed URL swap over rather than guessing. And if the *.lovable.app URL picked up real backlinks while you were building in public, a 301 is the mechanism that passes them along: a canonical alone leaves them pointing at a URL Google may stop crawling.
Trap 3: the trailing-slash fork
/pricing and /pricing/ are different URLs. Client-side routers cheerfully accept both, and most static hosts will serve both with a 200. You now have two indexable URLs for one page, and the canonical usually gets baked with whichever form the developer happened to type.
Pick one form, permanently redirect the other at the host, and make the canonical, the internal links and the sitemap all agree with the survivor. Consistency matters more than which one you pick.
Trap 4: tracking parameters
?utm_source=, ?ref=, ?fbclid=: every share generates a new URL that renders identical content. There is no upper bound on how many of these can exist, and Google no longer offers a URL-parameter tool to tell it to ignore them.
The canonical is the tool. A self-referencing canonical must point at the clean path, with the tracking parameters stripped:
Requested: https://acme.com/pricing?utm_source=newsletter&utm_campaign=sep
Canonical: https://acme.com/pricing
Note the asymmetry with trailing slashes: you redirect slash variants, you canonical parameter variants. Redirecting would strip the parameters your analytics needs before they’re recorded.
Keep meaningful parameters out of the stripping logic. ?page=2 is a distinct page with distinct content and needs its own self-referencing canonical: canonicalling pages 2..n back to page one de-indexes every item that only appears deeper in the list.
Trap 5: a canonical only a rendering crawler can see
Google permits JavaScript-injected canonicals, and its docs say to make sure you inject the link element properly. Googlebot renders, so it will find one. This makes client-side injection look like a working fix.
It isn’t, on a client-rendered app, because Googlebot is the only major consumer that renders reliably. Bing says it can process JavaScript but that doing so at scale is difficult, and recommends serving rendered HTML instead. Per Vercel and MERJ’s crawler study (measured December 2024: note the vintage), none of the major AI crawlers execute JavaScript: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, Meta-ExternalAgent, Bytespider. Applebot is the documented exception; Apple states it may render content within a browser. Link-preview bots generally sit in the same non-rendering category, though only Slack documents it outright: its unfurler uses HTTP Range requests, which cannot boot a JS runtime at all. Meta caps Open Graph parsing at the first 1MB of the source document, which is the signature of a streaming parser rather than a browser.
The canonical has to be in the bytes of the response. On TanStack Start it is when you declare it in the route’s head:, because that is part of what the server renders. Declared in a component effect instead, it isn’t: SSR doesn’t run effects, so the server emits the head the route defined and nothing more. On an older React + Vite project, Lovable’s docs say its pre-rendering runs the page before serving it to verified crawlers, which would include an injected canonical. That can’t be verified from outside, and it only applies on Lovable’s hosting and to the crawlers it recognizes. Export to GitHub and deploy on Vercel or Cloudflare Pages and it does not come with you. The crawlability guide covers what that costs you beyond canonicals.
Canonical, redirect, or noindex?
Three tools, three jobs. Most canonical damage comes from reaching for the wrong one.
| Situation | Use | Not |
|---|---|---|
| Two live URLs, same content, both must stay reachable | rel="canonical" on the duplicate, pointing at the keeper |
A redirect: you’d break the URL someone needs |
One URL permanently replaced another (old slug, *.lovable.app after a custom domain) |
301/308 redirect | A canonical alone. It’s a hint; the redirect is the instruction |
| Trailing-slash variants | Pick one form, 301 the other, self-canonical on the survivor | Canonicalling one to the other and leaving both 200 |
?utm_*, ?ref=, ?fbclid= |
Self-referencing canonical to the clean path | A redirect: it destroys the tracking data |
Paginated pages (?page=2) |
Self-referencing canonical on each page | Canonical to page 1, which de-indexes deeper items |
| Pages that should never be indexed (thank-you, internal app routes) | noindex, kept crawlable so the tag can be read |
Canonical to the homepage; and never Disallow the URL, since a blocked page can’t be read for its noindex |
| A genuinely worthless duplicate | Delete it and 301 to the survivor | Leaving it up with a canonical and hoping |
Two combinations to avoid outright. Don’t put noindex and a canonical pointing elsewhere on the same URL: you’re saying “drop this page” and “merge this page” at once. And don’t Disallow a URL in robots.txt to keep it out of the index: Google can still index a disallowed URL and show it without a snippet, and it can never read a noindex on a page it was told not to fetch.
What Lovable’s built-in check covers
Lovable’s SEO and AI search review includes a wrong-canonical check, alongside duplicate-title, placeholder-text and weak-description checks, and it grades findings by severity: green for passing, a blue lightbulb for low impact, amber for medium, red for high. It also checks canonical tags as part of a broader sweep covering semantic HTML, alt text and indexing status.
Use it, then verify independently. The review tells you a canonical is wrong, which is not the same as confirming the replacement shipped: publishing on Lovable is a snapshot, so edits made after your last publish are not on the live site at all. A review of your project is also not a fetch against your production host, which is where the trailing-slash and subdomain forks actually live.
Verify it in 60 seconds
Run this against the live domain:
for route in /blog/hello-world /products/some-item /pricing /; do
printf '%s -> ' "$route"
curl -s "https://yourdomain.com$route" \
| grep -oE '<link[^>]*rel="canonical"[^>]*>' \
|| echo '(no canonical in raw HTML)'
done
Start with pages that load their content dynamically (anything that isn’t written into the page’s source code). Those are the diagnostic ones: a blog post or a product page is where the head tags are most likely to be assembled from fetched data, so that is where a client-injected canonical shows up as a blank. The homepage tells you about trap 1 and nothing at all about rendering, because it usually fetches nothing.
Three signatures to look for in the output:
- The same canonical on every route. Trap 1. Every page but one is pointing at the wrong URL.
- A canonical containing
lovable.appwhile you’re on a custom domain. Trap 2. - No canonical in the raw HTML. Either it’s client-injected (trap 5), or there isn’t one. On TanStack Start, go and look at where the tag is declared: in the route’s
head:it is server-rendered, set from a component effect it is not.
Sending a Googlebot user agent is the obvious next move:
# This does NOT tell you what Googlebot sees.
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
https://yourdomain.com/pricing | grep -oE '<link[^>]*rel="canonical"[^>]*>'
On the React + Vite stack an empty result here means nothing either way, because what verified crawlers receive can’t be verified from outside. What you can check is the outcome: Search Console’s URL Inspection shows which canonical Google selected. And that pre-rendering is scoped to Lovable’s hosting: it stops the day you deploy somewhere else.
What to do next
- Run the loop above against five real routes on your production domain and write down what came back.
- If every route shows the same canonical, remove the hardcoded
<link rel="canonical">fromindex.htmlbefore adding per-route tags, otherwise you’ll have two competing tags in one<head>. curl -sIyour*.lovable.appURL. Anything other than a 301 to the custom domain is a task.- Decide your trailing-slash form once, then make the sitemap, the internal links and the canonicals match it.
- Re-run the loop after your next publish. Canonicals regress quietly when route code gets regenerated, and nothing in the UI will tell you.
Frequently asked questions
- Should a Lovable canonical tag point to lovable.app or my custom domain?
- Point it at the custom domain, on every route, once the custom domain is the version you want in search results. A canonical pointing back to the *.lovable.app subdomain asks Google to index the subdomain instead, which is almost never what you want.
- Does every page need a canonical tag?
- Google recommends adding a self-referential rel=canonical to the canonical page itself, so the practical answer is yes: every indexable URL should declare itself, using its own absolute URL. A single shared canonical across all routes is worse than having none.
- Is a canonical tag enough to fix duplicate content between two domains?
- No. Google treats rel=canonical as a signal it weighs alongside others, and it can ignore it. When one URL has permanently replaced another, a 301 redirect is the instruction; a canonical is the hint. Use the redirect where you can, and keep the canonical consistent with it.
- Can I add canonical tags with JavaScript in a React app?
- Google permits it and says to inject the link element properly, so Googlebot will see it after rendering. But per Vercel and MERJ's December 2024 crawler study, the major AI crawlers do not execute JavaScript, and Slack documents its unfurler as using HTTP Range requests, so client-side injection alone is the wrong answer for a client-rendered app.
- Does Lovable check canonical tags for me?
- Lovable's SEO and AI search review includes a wrong-canonical check alongside duplicate-title, placeholder-text and weak-description checks, and grades findings by severity. It tells you a canonical is wrong; it does not remove the need to verify the corrected tag in the raw HTML yourself.
Read next
Keep going
-
Per-page titles and meta descriptions in Lovable
Every route of a client-rendered app ships the same title. How to set Lovable meta tags per route on TanStack Start and Vite, and where the built-in checks stop.
-
Lovable site not showing up on Google
A Lovable app not indexed on Google is usually not a rendering bug, but check anyway. Seven causes ranked by likelihood, with the curl test that proves which one.
-
Making a Lovable app crawlable
Lovable crawlability comes down to links as much as rendering: real anchors, history routing, honest status codes, and a crawl you can run yourself.
-
Lovable SEO: the complete 2026 guide
Lovable SEO splits across two stacks, and SSR alone does not put your data in the HTML. Test both on your own routes, then close the gaps Lovable leaves to you.