Why Google calls your SPA a duplicate
Lesson 1 · mission: fix “Duplicate without user-selected canonical” on a Vite React SPA + Supabase
Google Search Console is telling you something precise, not vague: it fetched several of your URLs, the HTML it got back was the same bytes every time, and none of those pages declared which URL should represent the group. This lesson gives you one win: you will be able to explain exactly why that happens to a client-rendered SPA — and predict which fixes work before trying them.
1. What Googlebot actually receives from your site
Your Vite build ships one index.html. Whatever URL is requested —
/, /rooms/12, /pricing — static hosting returns
that same file, and React Router + Supabase queries build the real page later, in
the browser.
Listen first: the snippet is a nearly empty HTML page. Its body
contains a single empty div with the id “root”, followed by a script tag
loading the JavaScript bundle. There is no page text, no title specific to the route,
and no canonical link. Every URL on the site returns exactly this.
Google indexes in two waves:
| Wave | What happens | When |
|---|---|---|
| 1 — Crawl | Googlebot fetches the raw HTML. No JavaScript runs. | Immediately |
| 2 — Render | The page goes into a render queue; later, a headless Chromium runs your JS (including Supabase fetches) and re-reads the result. | Later — hours to weeks, not guaranteed to succeed |
Duplicate detection and canonical grouping start at wave 1. Identical HTML for
many URLs → Google clusters them as one page, elects one canonical itself, and stamps
the rest “Duplicate without user-selected canonical” — “user” meaning
you, the site owner, because your HTML never said
<link rel="canonical">.
2. Micro-world: be the Googlebot 🕷️
Crawl a tiny three-page site yourself. Pick how the site is built, run wave 1, then wave 2, and watch Google’s duplicate clusters and the GSC verdicts form.
Googlebot crawl simulator
Nothing crawled yet.
Things to try: run mode A and see all three URLs collapse into one cluster. Then run mode B and notice wave 1 looks identical to A — the canonical tags only appear in wave 2, where one page’s Supabase fetch times out and stays a shell (this happens on real sites: the render is delayed, budgeted, and fallible). Then run mode C and see every URL indexed at wave 1, no rendering needed.
3. So the problem is really three stacked facts
- Wave 1 sees no content. Every route returns the same empty shell, so by content, your URLs are duplicates at crawl time.
- No canonical is declared in the HTML Google trusts most. The raw response
has no
<link rel="canonical">, so the cluster has no user-selected representative → the exact GSC message you’re seeing. - Wave 2 is not a rescue. Rendering is delayed and rationed (crawl/render budget), and your content depends on runtime Supabase requests that can time out or fail inside Google’s renderer. Tags injected by JavaScript are seen late or never.
Objection: “But Google says it renders JavaScript now!” — It does, eventually, best-effort. Canonicalization and duplicate grouping still begin from the raw HTML, and Google’s own docs tell you not to rely on the renderer for critical tags. For a small site, weeks of “eventually” looks exactly like your GSC report.
4. The fix menu (for Vite + static Supabase hosting)
| Option | What it does | Verdict for you |
|---|---|---|
Build-time prerendering (SSG) — e.g. vite-react-ssg, vike,
or a custom script that fetches Supabase data at build time and emits one real
HTML file per route |
Wave 1 sees unique content + unique <title>, meta description,
and <link rel="canonical"> per URL |
Recommended. Works on pure static hosting; no server needed. Re-run the build (or a scheduled rebuild) when Supabase data changes. |
| react-helmet / client-side canonical only | Tags appear only after wave-2 rendering | Not sufficient alone — this is mode B in the simulator. Do add the tags (they help once rendered), but don’t expect them to fix the report. |
| SSR migration (Next.js, React Router v7 framework mode) | Server renders real HTML per request | The thorough fix, but a big change and needs a server/edge runtime — out of scope for now per your mission. |
URL hygiene + sitemap — one host (www vs non-www), one scheme, no
duplicate paths, submit sitemap.xml listing only canonical URLs |
Gives Google consistent signals about which URLs exist | Do this regardless — cheap, and duplicate clusters often include
?query/host variants you didn’t notice. |
/rooms/12 after prerendering.Listen first: after prerendering, the HTML for the room page contains a route-specific title (“Deluxe Room — My App”), a meta description, a canonical link pointing to its own full URL, and the actual room content inside the root div — before any JavaScript runs. React then “hydrates” on top of it.
5. Check yourself ✍️
Every route of my SPA returns the same empty
index.html; Google’s wave-1
crawl sees identical bytes with no rel=canonical, clusters them as
duplicates, and picks its own representative. JS-injected tags arrive only in the
unreliable wave-2 render, so the fix is to make wave 1 unique: prerender each route
with its own content and canonical at build time.
Read next
- Lesson 2 (planned): implementing prerendering for your Vite + Supabase routes
Primary source
Google Search Central — JavaScript SEO basics (~10-min read) — the official description of the crawl → render-queue pipeline this lesson simulates. Pair with How Google chooses canonical URLs (~8 min).
💬 I'm your teacher for this — ask me followups any time.
Lesson 1 · workspace teach/seo · mission: fix “Duplicate without user-selected canonical”