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.
$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.
| You want to | Use |
|---|---|
| Load data for a page or component from one URL | useFetch |
| Load data from something that isn't a URL: an SDK, a database client, several calls combined | useAsyncData |
| Combine two requests into one result | useAsyncData 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 components | either 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.
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.
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.
$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').
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.
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.
serialize: false or server: falseserialize: 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.
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.
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.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.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'))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.
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.