The Nuxt 4 release notes sell the new data-fetching story as an incremental cleanup. From the outside, that's true: the function signatures of useAsyncData and useFetch haven't moved, the return shapes are identical, and most existing call sites keep working without edits. It's inside the composables — the cache keys, the hydration payload, and the request deduplication — that the rules have quietly changed. And because the API didn't change, the bugs look like your bugs rather than Nuxt's.

This post is what I wish someone had handed our team on day one of the migration: the three behaviours that actually moved between v3 and v4, the two footguns that are new in v4, and a small set of mental models that kept us from re-writing the fetch layer twice. The composables are the same. The guarantees around them are not.

01What the rename did not change

It is worth being precise about what did not change, because most of the migration anxiety online is about things that are still true. The useAsyncData composable still accepts a key and a handler. useFetch is still a wrapper over useAsyncData that also knows about URLs and request bodies. Both still return the same { data, pending, error, refresh, execute, status } shape, and both still participate in the SSR payload so that the client does not re-fetch on hydration.

If your v3 codebase was written in the intended style — one call per logical piece of data, keys derived from the URL rather than reused across components, and refresh rather than a manual re-render to pick up a change — you will not have to rewrite a line. If it was not, the migration is going to expose whichever shortcut you took. Which brings us to the part that did change.

02Cache keys, and why yours might be lying

In Nuxt 3, when you called useFetch('/api/invoices') from two components on the same page, the composable deduplicated the request by URL. Nuxt 4 deduplicates by key, and the auto-generated key is no longer the URL — it's a hash that includes the call site. Two call sites fetching the same URL now produce two network requests unless you pass a shared key option.

composables/useInvoices.tsNuxt 4 · explicit key
// Before (Nuxt 3) — dedup was implicit on URL:
const { data } = await useFetch('/api/invoices')

// After (Nuxt 4) — pass a key to dedupe across components:
const { data } = await useFetch('/api/invoices', {
  key: 'invoices:list',
})

The first time I hit this, the symptom was a payroll dashboard that fired the same /api/invoices call three times on load. The fix is two characters of YAML and a rule: every fetch call gets an explicit key. Auto-generated keys are fine for prototypes; they are not a contract you want to rely on.

Footgun If the same URL is fetched from both a server/ route handler and a Vue component on the same page, the server call does not participate in the payload dedupe unless you route it through useFetch with a matching key. Two fetches, one SSR payload entry, hydration mismatch.

03The SSR payload, smaller and stricter

Nuxt 4 trims the SSR payload aggressively. In v3, the response body was serialized in full and shipped to the client, which is why useFetch on hydration was effectively a no-op. In v4, the payload is serialized only for the keys the client will actually consume on hydration, and anything reachable only from a <ClientOnly> subtree is dropped.

Behaviour Nuxt 3 Nuxt 4
Dedup key URL (implicit) Hash of call site + URL
Payload coverage All fetches, always Only fetches reachable on SSR
ClientOnly children Payload still shipped Payload omitted
Auto-imported composables From composables/ Also shared/
Refresh on navigation Opt-in via watch Opt-in via getCachedData

The practical upshot: a fetch inside <ClientOnly> that used to warm the cache on SSR now always runs on the client. For UX this is usually what you want — unless you were unknowingly depending on the payload to seed a cache a few components deep. Audit your ClientOnly boundaries once; the second time is not worth it.

"The composables are the same. The guarantees around them are not."

04getCachedData — the new seam

The single largest practical change is getCachedData, a new option on useAsyncData. It is how you tell Nuxt: if I've already fetched this, don't re-fetch — hand me what you have. It runs before the handler, and if it returns a non-nullish value, the handler is skipped entirely.

composables/useCurrentUser.tsNuxt 4
export const useCurrentUser = () => {
  const nuxt = useNuxtApp()
  return useAsyncData('user:me', () => $fetch('/api/me'), {
    getCachedData: (key) => nuxt.payload.data[key] ?? nuxt.static.data[key],
  })
}

This is the piece that replaces a grab-bag of Nuxt 3 idioms: the initialCache option, watch-based invalidation, and the pattern of setting lazy: true and then manually reading from $nuxt. One option, one place to put the logic. It's also the only seam for client-navigation caching — if you want a useFetch to not re-run when the user returns to a page, getCachedData is where it goes.

05Rules that survived the migration intact

A short list, kept only because writing it down once saved the team three arguments:

  1. Always pass an explicit key. Auto-keys are convenient in a single-component demo and a footgun in a real app. One key per logical piece of data.
  2. Fetch at the page, not in the leaves. Components deep in the tree should receive data through props or a shared composable. A leaf that fetches its own data cannot participate in getCachedData at the page level.
  3. Use $fetch for fire-and-forget. useFetch allocates a reactive data ref; if you don't need one, don't pay for one.
  4. Reach for useLazyAsyncData before <Suspense>. The lazy variants exist for exactly the cases where you want the page to render before the fetch resolves.

06What's left

The v3-to-v4 story is not a rewrite; it is a tightening. The composables are stricter about what they'll do for free, and more cooperative with a caching layer you actually design. If your team writes one key per fetch, fetches at the page boundary, and understands getCachedData as the cache seam, the migration is eight files and an afternoon. If not, the migration will find every place you didn't.

Next note in this series: the shared/ folder, and why it replaces a category of auto-import hacks that most teams did not realise they were relying on.¹

Footnotes & references

  1. ¹ See § 06.03 — The shared folder in Nuxt 4. Drafted alongside this note; published when the migration at work clears its last blocker.
  2. ² Code samples tested on Nuxt 4.0.2, Vue 3.5.13, Node 22.4. Corrections welcome via email; the revision log is at the top of this page.
  3. ³ Official RFC: Nuxt Data Fetching v4 — useful for the "why," less so for the day-to-day. This note is the day-to-day.