Last week, Google Search Console flagged three of my five newest blog posts: "Duplicate, Google chose different canonical than user". That happens. What doesn't usually happen is Google picking the same canonical for all three: an older, unrelated post. A post about B2B software pricing, one about time tracking and one about online booking for driving schools, all folded into an article about small businesses in Rennes.
Three different topics collapsing into one URL doesn't look like "similar content". It looks like Google saw the same page three times.
My site is a static Nuxt 4 build (SSG) with Storyblok as the CMS. So I started with the boring checks: Each URL returns a 200 with its own , , canonical and hreflang tags. The static HTML contains the full article. Search Console's "Test live URL" renders every article correctly.
The article was already in the HTML, yet the browser called the Storyblok API twice on every visit:
A top-level await in does run during prerendering, so the generated HTML is complete. But runs again in the browser when the page hydrates, and nothing tells Nuxt that this data already exists. useAsyncData and useFetch store their result in the page payload; a bare await doesn't. So every visit fetched the story again, plus the spaces/me call my service uses to get Storyblok's cache version.
The catch made it worse. If that browser request failed (rate limit, flaky network, a blocked request), the fatal 404 replaced a perfectly good prerendered article with the error page.
Googlebot renders JavaScript. It fetches the HTML, then runs the app like a browser would, often for many URLs in a row. If the CMS call fails while it renders, every affected URL shows the same error screen. The same content on several URLs is exactly what gets clustered as duplicates, with one canonical picked for the whole group.
To be honest, I can't prove that's what happened here. The live test renders fine today, and older posts running the same code did get indexed. But it's a real failure mode, the fix is small, and it removes two API calls from every page view anyway.
During nuxt generate, the handler runs once and its result is written to /blog//payload.json. When the page hydrates, and when you navigate to a prerendered route client-side, Nuxt reads that payload instead of running the handler again. The service now returns null instead of throwing, and since the browser reads the payload, the 404 only fires when the story really doesn't exist at build time.
Two details that matter: The key must be identical on the server and in the browser, and unique per page (locale + slug here). My project pages had the exact same pattern, so they got the same fix.
Result: zero Storyblok calls in the browser, on first load and on internal navigation.
While I was in there, I looked at the payload itself. Each article page weighed 217 KB. The "related articles" block was fed by a list of 24 articles, with their full rich-text bodies, just to render three cards.
An article payload went from 217 KB to 32 KB, and the blog index from 112 KB to 12 KB.
How to check your own Nuxt site Open a prerendered page with DevTools, Network tab, filtered on your CMS or API host. On a static page, it should stay empty. Or paste this in the console: Look for top-level await calls in your pages that aren't wrapped in useAsyncData or useFetch. Check the size of //payload.json: it's downloaded and parsed on every visit. In Search Console, "Test live URL" then "View tested page" shows what Googlebot actually rendered.
Takeaways In a prerendered Nuxt app, a bare await in runs twice: at build time and in the browser. Wrap data loading in useAsyncData or useFetch so the payload feeds hydration. Don't throw a fatal error from code that can run in the browser for data you already prerendered. Keep list payloads lean: cards don't need article bodies.
I've asked Google to recrawl the three posts. I'll update this article with what Search Console says.
I'm a freelance developer in Rennes, France, building custom business tools and websites for small businesses. The site this happened on, and more write-ups like this one: dibodev.fr.
