Build1 publisher3 min readPublished
Prerendered Nuxt 4 blog pages refetched their articles from Storyblok on every browser visit
Nuxt 4 pages prerendered from Storyblok made two CMS API calls on every browser visit because a bare await re-ran on hydration. The developer found it while chasing three posts Google had merged under one canonical, a link the developer says cannot be proven.
The Engineer · Build desk

What happened
- Search Console flagged three of a developer's five newest posts as duplicates and gave all three one canonical, an older unrelated post about small businesses in Rennes.
- Status codes, per-page titles, canonical and hreflang tags, the static HTML and Search Console's live URL test all checked out on the affected articles.
- The browser's Network tab showed two Storyblok API calls on every visit to a prerendered article, one to spaces/me and one fetching the story by slug.
- After the fetch moved into useAsyncData with a per-page key, the site made zero Storyblok calls from the browser on first load and on internal navigation.
- Each article page's payload weighed 217 KB, because the related-articles block received 24 full articles with rich-text bodies to render three cards.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Build-output checks and a passing live URL test leave a hydration refetch invisible, so catching one means watching the page's network requests after it loads.
- exposure With a bare await and a fatal catch, a correct prerendered page depends on the CMS answering at view time, rate limits included.
- decision Teams with the same pattern can justify the change on removed API calls and error risk alone, before any effect on indexing is proven.
- cost Once hydration and client-side navigation read _payload.json, anything that bloats that file is paid for by every reader who loads or navigates to the page.
The site is a static Nuxt 4 build with Storyblok as the CMS [2]. Its article page loaded data with a top-level `await` inside `<script setup>` [5]. That code runs during prerendering, so the generated HTML is complete [5]. It runs again in the browser when the page hydrates, and nothing tells Nuxt the data already exists [5]. According to the developer's write-up, `useAsyncData` and `useFetch` store their result in the page payload, and a bare `await` does not [5]. The `spaces/me` request came from the service fetching Storyblok's cache version before it asked for the story [6].
The error handling made the extra request risky. The `catch` threw a fatal 404, so a rate limit, a flaky network or a blocked request in the browser replaced a good prerendered article with the error page [7]. The developer's theory starts there. Googlebot renders JavaScript, often for many URLs in a row, and if the CMS call fails during those renders, every affected URL shows the same error screen [8]. Identical content on several URLs is what Google clusters as duplicates, with one canonical picked for the group [8].
"To be honest, I can't prove that's what happened here," the developer wrote [9]. The live test renders fine today, and older posts running the same code did get indexed [10]. The Network tab found a real defect after the other checks had passed [3][4], but the evidence stops short of tying that defect to the canonical folding [9][10].
The fix is small, and I think it is the right one. The fetch moved into `useAsyncData`, keyed as `blog-article-${locale}-${slug}` [11]. During `nuxt generate`, the handler runs once and its result is written to `/blog/<slug>/_payload.json` [11]. On hydration, and on client-side navigation to a prerendered route, Nuxt reads that file and does not run the handler again [11]. The service now returns `null` instead of throwing, so the 404 fires only when the story did not exist at build time [12]. Project pages had the same pattern and got the same fix [14].
The payload needed its own trim. Eight articles went out for every related-article card the page showed [18]. The new code computes reading time from each body, then sets `content` to null, because cards need only title, excerpt, cover and tags [17]. The post does not give the payload size after the change.
Two conditions have to hold for the same bug to exist on another site. Pages are prerendered, and data is loaded with a bare `await` instead of `useAsyncData` or `useFetch` [5]. The fix then adds a requirement of its own: the key has to be identical on server and browser and unique per page [13]. On this site, putting both locale and slug in the key satisfies that [11][13].
What to watch
- Whether Search Console drops the duplicate-canonical flag on the three posts once Google recrawls the useAsyncData version.
- Whether the developer publishes the article payload size after stripping rich-text bodies from the 24-item related-articles list.
- Reports from other prerendered Nuxt sites on Storyblok finding the same bare-await refetch in their Network tabs.