50% off for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 4, 2026

useFetch vs useAsyncData vs $fetch in Nuxt

useFetch vs useAsyncData vs $fetch in Nuxt 4: one decision table for all three, how keys work, and five cases where a page fetches the same data twice.
Nuxt
Data Fetching
Vue
SSR

The useFetch vs useAsyncData question has a short answer: useFetch is useAsyncData plus $fetch with the key written for you, and $fetch alone is for requests the user triggers. The longer answer matters because picking the wrong one is how a Nuxt page ends up calling the same API twice. This post gives one decision table for all three, then walks through the cases where each of them fetches twice and how to stop it. Everything here follows the Nuxt 4 docs.

What each one is

$fetch is Nuxt's global HTTP helper, built on ofetch. It's a plain function: you call it, it returns a promise. It knows nothing about server rendering. During SSR, when it calls one of your own API routes, Nuxt calls the route's handler directly instead of making a real HTTP request.

useAsyncData is a composable that runs any async function (the handler) and stores the result under a key. When the handler runs on the server, the result goes into the Nuxt payload, and the client reads it from there during hydration instead of running the handler again.

const { data, status, error, refresh } = await useAsyncData('user', () => $fetch('/api/user'))

useFetch is a wrapper around useAsyncData and $fetch for the common case: one URL. It builds the key from the URL, the options and where the call sits in your code. It also infers the response type from your server routes.

const { data, status, error, refresh } = await useFetch('/api/user')

Both composables return the same refs: data, status ('idle', 'pending', 'success' or 'error'), error, plus refresh, execute and clear. In Nuxt 4, data and error start as undefined, not null.

useFetch vs useAsyncData: the decision table

You want toUse
Load data for a page or component from one URLuseFetch
Load data from something that isn't a URL: an SDK, a database client, several calls combineduseAsyncData
Combine two requests into one resultuseAsyncData with Promise.all inside
Send a request when the user clicks, submits or types$fetch
Call an API from a server route$fetch
Fetch only in the browser (data that depends on window, or isn't needed for SEO)useFetch or useAsyncData with server: false
Share the same data between two componentseither composable, with the same explicit key

The rule underneath the table: if the data is needed to render the page, use a composable. If it's a reaction to the user, use $fetch. The docs put it the same way: $fetch is recommended for client-side, event-based interactions, and the composables are meant to be called directly in a setup function, a plugin or route middleware.

Here's the classic split in one component:

<script setup lang="ts">
const { data: todos, refresh } = await useFetch('/api/todos')

async function addTodo(title: string) {
  await $fetch('/api/todos', { method: 'POST', body: { title } })
  await refresh()
}
</script>

The list loads through useFetch, so it is rendered on the server and not fetched again on hydration. The POST runs only when the user acts, so $fetch is right.

When useAsyncData is the better fit

useAsyncData earns its place when there is more than one request, or none at all:

const route = useRoute()

const { data } = await useAsyncData(
  () => `dashboard-${route.params.id}`,
  async (_nuxtApp, { signal }) => {
    const [project, stats] = await Promise.all([
      $fetch(`/api/projects/${route.params.id}`, { signal }),
      $fetch(`/api/projects/${route.params.id}/stats`, { signal }),
    ])
    return { project, stats }
  },
)

The key is a getter, so when route.params.id changes, the data is fetched again. The handler receives an AbortSignal; pass it to $fetch so a superseded request is cancelled. By default dedupe is 'cancel', which aborts the in-flight call when a new one starts.

How keys work

The key is what ties the server result in the payload to the call on the client, and it decides which calls share state.

  • useAsyncData('user', ...) uses 'user'. Without a key, it generates one from the file and line of the call.
  • useFetch('/api/user') generates a key from the URL, the options and the call site.

That last part surprises people: two useFetch('/api/user') calls in two different components get different keys, so they don't share data. Instances of the same component do share it. To share across components, pass the same key:

// app/components/UserMenu.vue and app/components/UserCard.vue
const { data: user } = await useFetch('/api/user', { key: 'current-user' })

Calls that share a key must also agree on the handler, deep, transform, pick, getCachedData and default. Nuxt warns in development when they don't. server, lazy, immediate, dedupe and watch can differ.

Five cases where Nuxt fetches twice

1. $fetch at the top of <script setup>

// fetches on the server, then again in the browser
const user = await $fetch('/api/user')

$fetch doesn't transfer state from server to client. The server renders the HTML with the data, then the client runs setup again during hydration and repeats the request. Wrap it: await useAsyncData('user', () => $fetch('/api/user')), or use useFetch('/api/user').

2. A handler that returns nothing

useAsyncData stores what the handler returns. The docs warn that the handler must return a truthy value, or the request may be duplicated on the client. A handler that only assigns to a ref and returns undefined hits this. Return the data, and use callOnce for code that exists only for its side effects.

3. Two components, same URL, no shared key

As above: two useFetch calls to the same URL from different components have different keys, so the page makes two requests. Give them one key, or fetch once in the parent and pass the data down.

4. serialize: false or server: false

serialize: false keeps the server result out of the payload, so the client fetches again after hydration if a component renders the data. server: false isn't a double fetch, but it moves the only fetch to the browser, after hydration. In both cases plan for an empty data on first render and show a loading state from status.

5. Client-side navigation back to a page

The default getCachedData reads the payload only while the app is hydrating. Navigate away and back, and the handler runs again. That's usually what you want (fresh data), but for data that rarely changes you can pass your own getCachedData:

const { data: countries } = await useFetch('/api/countries', {
  key: 'countries',
  getCachedData: (key, nuxtApp, ctx) =>
    ctx.cause === 'refresh:manual' ? undefined : nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
})

Returning a value skips the request; returning undefined lets it run. ctx.cause tells you why the request is happening ('initial', 'refresh:manual', 'refresh:hook' or 'watch'), so a manual refresh() still gets fresh data.

The options that change behavior

  • lazy: true doesn't block navigation. await returns right away on client-side navigation, so handle the pending state. useLazyFetch and useLazyAsyncData are the same thing.
  • immediate: false doesn't run until you call execute(). status stays 'idle' until then.
  • Reactive options refetch on change. With useFetch, a ref in query or a computed URL triggers a new request. Note that watching a ref with the watch option re-runs the request but does not change the URL: use a computed or getter URL for that. watch: false turns auto-refetching off.
  • pick and transform shrink what goes into the payload, not what is fetched. The server still downloads the full response.
  • deep defaults to false, so data is a shallow ref. Replacing data.value triggers updates; mutating a nested property doesn't.

Cookies on the server

In the browser, cookies go along with $fetch automatically. On the server they don't: $fetch doesn't forward the user's request headers. useFetch with a relative URL forwards them for you through useRequestFetch. With useAsyncData, do it yourself:

const requestFetch = useRequestFetch()
const { data: session } = await useAsyncData('session', () => requestFetch('/api/session'))

Common questions

Is useFetch slower than useAsyncData? No. It is useAsyncData underneath. Pick by what you're fetching, not speed.

Can I call useFetch in a click handler? It's meant for setup, plugins and route middleware. In a handler, use $fetch and then refresh() the composable that owns the data.

Why is data undefined after await? Either lazy: true on a client-side navigation, or server: false on the first load. Check status.

Where do the API routes go? In server/api/. The Nuxt 4 folder structure guide covers that layout and what can be imported from where.

Next step

Data fetching is one layer of an app. The screens on top of it (dashboards, charts, reports and task pages) take far longer to build. The QuasarUI Templates are four complete Quasar admin dashboards with light and dark mode: wire your composables to their pages instead of building them from nothing.