useAsyncData in Nuxt 3: Choosing Between Server Fetch, Client Fetch, and the Cache You Forgot About
Most Nuxt SSR data bugs come from useAsyncData options nobody consciously chose. Here's how to decide between server and client fetching, work the cache deliberately, and verify the result in a production preview.
07 Oct 2026, 15:39 UTC

Your Nuxt 3 page renders fine in dev, ships to production, and then someone reports a flash of empty content on first load — or worse, a hydration mismatch warning in the console. In most cases the root cause is not Vue at all. It is a useAsyncData call whose server option and caching behavior were never consciously chosen.
The useful takeaway: useAsyncData is not just a fetch wrapper. It is a decision point about where data loads, when the UI unblocks, and how long results live. Making those three choices deliberately eliminates most SSR data-fetching bugs.
Server fetch is the default for a reason
With server: true (the default), Nuxt runs your handler during server-side rendering, serializes the result into the page payload, and hands it to the client during hydration. The browser never re-fetches. That gives you three things at once: no layout shift, SEO-friendly HTML with real content, and no loading spinner on first paint.
The cost is that your handler now runs in a Node context. Errors thrown during SSR can surface as 500 responses, and anything depending on window or browser-only APIs will fail. Wrap risky calls and return a safe fallback instead of letting exceptions escape.
When client-side fetching is the right call
Setting server: false defers the fetch until after hydration. This is the correct choice when the endpoint rejects server IPs, when the request needs a browser-only credential, or when the data is personalized and you do not want it in the shared SSR payload anyway.
The trade-off: the user sees a pending state after the page paints. Pair it with an explicit loading UI driven by the pending ref, or wrap the component in a suspense boundary, so the gap reads as intentional rather than broken. Be aware that server: false also means the endpoint is hit directly from the browser — do not rely on the server to hide an internal API behind it.
A worked example: one composable, two modes
Here is a pattern for a product page that loads public catalog data on the server but defers a user-specific pricing call to the client. This runs in any Nuxt 3 page or component setup block; no special permissions are needed.
// pages/products/[id].vue
const route = useRoute()
// Public data: fetched during SSR, cached, included in payload
const { data: product, error } = await useAsyncData(
`product-${route.params.id}`,
() => $fetch(`/api/products/${route.params.id}`)
)
if (error.value) {
throw createError({ statusCode: 404, statusMessage: 'Product not found' })
}
// Personalized data: browser-only, shows a pending state
const { data: pricing, pending: pricingPending } = useAsyncData(
`pricing-${route.params.id}`,
() => $fetch(`/api/pricing/${route.params.id}`, {
headers: { authorization: `Bearer ${useUserSession().token}` }
}),
{ server: false }
)To verify the split worked, open the Network tab after running nuxt preview (a production-like local build): the product request should be absent from the browser because its data arrived in the SSR payload, while the pricing request appears after initial paint. In Vue Devtools, pricingPending should start as true and resolve after mount.
The cache is automatic — and that cuts both ways
Nuxt derives a cache key from the key you pass (or auto-generates one from the file and line). Components using the same key share one result without a store. That is genuinely convenient, but it means stale data is your responsibility. In dev, hot-reloading refreshes refs and can mask staleness; in production, cached entries persist until invalidated.
Practical mitigations: use refresh() on user actions that mutate data, call clearNuxtData('key') after logout or account switches, and include volatile inputs (like the route param above) directly in the key so navigation does not reuse the wrong entry.
| Concern | server: true | server: false |
|---|---|---|
| First paint | Full content, no spinner | Pending UI after hydration |
| SEO | Content in HTML | Content absent from HTML |
| Credentials | Server-side only | Browser tokens usable |
| Failure mode | Possible 500 during SSR | Client error state |
The limitation worth respecting
Hydration mismatches happen when server-rendered HTML and the client's first render disagree — classically when the handler returns something environment-dependent like Date.now(), random values, or locale-formatted dates computed differently in Node and the browser. If you see mismatch warnings, check the handler for nondeterminism before blaming Vue. Nested suspense boundaries on slow networks can produce similar symptoms, so keep suspense usage shallow until you have verified behavior in nuxt preview, not just dev.
A checklist before you ship
- For every
useAsyncDatacall, write down why it is server or client — if you cannot, it is a default, not a decision. - Confirm in the Network tab of a production preview that server fetches do not re-fire in the browser.
- Give every key its volatile inputs (IDs, filters, locale) so the cache cannot cross-contaminate pages.
- Define an invalidation path (
refresh,clearNuxtData) for any data the user can change.
Behavior described here reflects Nuxt 3's stable useAsyncData API; check the official API reference for your minor version, since options like lazy and dedupe have evolved across releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.