DEV Community

Léo Guillaume (Dibodev)
Léo Guillaume (Dibodev)

Posted on Fully Autonomous

My Nuxt pages were prerendered. Why were they still calling the CMS in the browser?

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 <title>, <h1>, canonical and hreflang tags.
  • The static HTML contains the full article.
  • Search Console's "Test live URL" renders every article correctly.

All fine. Then I opened the Network tab on one of those prerendered articles.

Two CMS calls on every page view

The article was already in the HTML, yet the browser called the Storyblok API twice on every visit:

GET https://api.storyblok.com/v2/cdn/spaces/me?token=…
GET https://api.storyblok.com/v2/cdn/stories/blog%2F<slug>?token=…&cv=…
Enter fullscreen mode Exit fullscreen mode

The culprit was how the page loaded its data:

<!-- pages/blog/[slug].vue (before, simplified) -->
<script setup lang="ts">
const route = useRoute()
const slug = String(route.params.slug)
const article = ref<Article | null>(null)

try {
  const response = await StoryblokArticleService.getArticleBySlug(slug)
  article.value = mapStoryblokArticle(response.story)
} catch {
  throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
}
</script>
Enter fullscreen mode Exit fullscreen mode

A top-level await in <script setup> does run during prerendering, so the generated HTML is complete. But <script setup> 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.

Why it can matter for indexing

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.

The fix: let the payload do its job

<!-- pages/blog/[slug].vue (after, simplified) -->
<script setup lang="ts">
const route = useRoute()
const { locale } = useI18n()
const slug = String(route.params.slug)

const { data: article } = await useAsyncData(
  `blog-article-${locale.value}-${slug}`,
  () => StoryblokArticleService.getLocalizedArticle(slug, locale.value),
)

if (!article.value) {
  throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
}
</script>
Enter fullscreen mode Exit fullscreen mode

During nuxt generate, the handler runs once and its result is written to /blog/<slug>/_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.

Bonus: each article shipped 217 KB of JSON

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.

// composables/useArticlesWithTranslations.ts
function withoutContent(article: Article): Article {
  return { ...article, content: null }
}

const articles = response.stories
  .map(mapStoryblokArticle) // reading time is computed here, from the body
  .map(withoutContent) // cards only need title, excerpt, cover, tags and date
Enter fullscreen mode Exit fullscreen mode

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

  1. Open a prerendered page with DevTools, Network tab, filtered on your CMS or API host. On a static page, it should stay empty.
  2. Or paste this in the console:
performance.getEntriesByType('resource')
  .filter((entry) => entry.name.includes('api.storyblok.com')) // your CMS or API host
  .map((entry) => entry.name)
Enter fullscreen mode Exit fullscreen mode
  1. Look for top-level await calls in your pages that aren't wrapped in useAsyncData or useFetch.
  2. Check the size of /<route>/_payload.json: it's downloaded and parsed on every visit.
  3. 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 <script setup> 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.

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

The failure mode where hydration replaces good static HTML with a client-side 404 is a useful one to isolate. For the recrawl, I'd capture the rendered HTML and network trace for each of the three URLs before and after the change, then compare Search Console's selected canonical only after its next crawl. That separates the fixed double-fetch from the indexing hypothesis you rightly haven't claimed to prove. The 217 KB -> 32 KB payload cut is a nice independent win.