useFetch vs useAsyncData: A Practical Guide to Nuxt Data Fetching
When to use useFetch, useAsyncData, useLazyAsyncData, and plain $fetch in Nuxt, what the server and lazy options actually do, and the Nuxt 4 changes that break Nuxt 3 code.
Nuxt gives you four ways to load data and no obvious signal about which one to pick. I have watched a team ship useFetch inside an onMounted hook, watched another wrap $fetch in a ref and rebuild request deduplication by hand, and spent an afternoon on a page that fetched everything twice because the key changed between server and client.
The choice is narrower than it looks. useFetch and useAsyncData are the same composable with different ergonomics, the Lazy variants are one preset option, and $fetch is the thing all of them call underneath. What follows is what each one does at runtime, and the three places the API will mislead you.
This is current as of Nuxt 4. Where Nuxt 4 changed behaviour that Nuxt 3 code depends on, I have said so inline.
The Short Answer
| Composable | Use it for | Blocks navigation | Key |
|---|---|---|---|
useFetch | An HTTP endpoint, nothing custom | Yes | Auto, from URL and options |
useAsyncData | Any async function, multiple sources, an SDK | Yes | You provide it |
useLazyFetch | An endpoint the page can render without | No | Auto |
useLazyAsyncData | Custom logic the page can render without | No | You provide it |
$fetch | Event handlers, mutations, anything after a user acts | N/A | None |
useFetch(url, opts) is close to useAsyncData(key, () => $fetch(url), opts). Use useFetch when that is all you need. Move to useAsyncData the moment the body of the handler is doing something other than a single request.
What All Four Share
Every composable except $fetch exists to solve one problem: the server already fetched this, so the browser should not fetch it again.
first load, SSR
server useFetch("/api/posts")
handler runs, response held
HTML rendered with the data in it
result serialised into the __NUXT__ payload
client hydrate
payload read from the document
no network request
client-side navigation to /posts
client useFetch("/api/posts")
handler runs in the browser, no payload to read from
lazy: false navigation waits for the response (default)
lazy: true navigation completes, data arrives after
That serialisation step is the whole value. $fetch on its own runs on the server during SSR, renders the HTML, and then runs again on the client during hydration, because nothing carried the first result across. Two requests, and a flash of different content if the data moved in between.
The second thing they share is deduplication. Two components calling useFetch("/api/user") on the same page produce one request.
useFetch
The default choice for hitting an endpoint.
<script setup>
const { data: posts, status, error, refresh } = await useFetch("/api/posts");
</script>
<template>
<p v-if="status === 'pending'">Loading posts</p>
<p v-else-if="error">{{ error.message }}</p>
<ul v-else>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</template>
The key is generated for you from the URL and the options, which is convenient and also the source of the duplicate-fetch bug I mentioned. If any part of the URL differs between the server render and the client hydration, the client computes a different key, finds no payload under it, and fetches again. A timestamp in a query string will do it.
Reactive URLs re-run the handler when their dependencies change. Pass a getter, not a string:
const page = ref(1);
// Refetches whenever page.value changes.
const { data: posts } = await useFetch(() => `/api/posts?page=${page.value}`);
A plain template literal is evaluated once and never updates. The getter form is the one that works, and watch: [page] is the alternative if the URL itself is static and something else should trigger the refetch.
Request and response hooks handle auth headers and error normalisation:
const { data } = await useFetch("/api/comments", {
method: "POST",
body: { postId: 1, comment: "Useful, thanks." },
onRequest({ options }) {
options.headers = { ...options.headers, authorization: `Bearer ${token.value}` };
},
onResponseError({ response }) {
console.error("Comment failed", response.status);
},
});
That example is the wrong shape on purpose in one respect: a POST that creates a comment belongs in a click handler with $fetch, not in a composable that runs on page load. See the $fetch section below.
useAsyncData
useAsyncData takes a key and any async function. Reach for it when the handler is doing real work.
Two endpoints in parallel:
const { data: dashboard } = await useAsyncData("dashboard", async () => {
const [orders, customers] = await Promise.all([
$fetch("/api/orders"),
$fetch("/api/customers"),
]);
return {
revenue: orders.reduce((sum, order) => sum + order.total, 0),
customerCount: customers.length,
};
});
One key, one payload entry, one hydration. Doing this as two useFetch calls also works, but you then shape the result in a computed and ship both raw responses in the payload instead of the two numbers you needed.
Trimming the payload is worth being deliberate about. transform runs before serialisation, pick is the shorthand for keeping named keys, and both cut what gets embedded in the HTML:
const { data: articles } = await useAsyncData(
"articles",
() => $fetch("/api/articles"),
{
default: () => [],
transform: (list) =>
list.map(({ id, title, publishedAt }) => ({ id, title, publishedAt })),
}
);
An endpoint returning 50 articles with full body text can put several hundred kilobytes into the document. transform down to the three fields the list renders and the payload shrinks to the size of the list.
default matters more than it looks. Without it, data is undefined until the first resolve. v-for tolerates that and renders nothing, but any expression that reaches into the value does not: articles.length in a heading, or articles[0].title in a preview, throws on the first render. Supplying default gives the template the right shape from the first tick and saves you a v-if on every consumer.
useLazyAsyncData and useLazyFetch
These are not separate implementations. useLazyAsyncData(key, handler) is useAsyncData(key, handler, { lazy: true }), and useLazyFetch stands in the same relation to useFetch.
lazy: true changes one thing: client-side navigation no longer waits for the handler. The route transition completes, the component renders, and the data lands when it lands.
<script setup>
const { data: related, status } = useLazyFetch("/api/articles/related");
</script>
<template>
<aside>
<p v-if="status === 'pending'">Finding related articles</p>
<ul v-else-if="related?.length">
<li v-for="article in related" :key="article.id">{{ article.title }}</li>
</ul>
</aside>
</template>
Note the missing await and the related?.length. Both are load-bearing.
Because navigation does not wait, data is undefined on the first render of every lazy call. A template that assumes otherwise throws on the transition, and only on the transition, so it will pass a first-load test and fail in use.
The rule I use: if the page is meaningless without the data, block on it. A product page without the product is a blank page, so that fetch blocks. A sidebar of related articles is decoration, so that one does not.
$fetch On Its Own
$fetch is the plain HTTP client. No key, no payload, no deduplication, no navigation blocking. It runs when you call it.
That makes it correct for everything the user triggers:
<script setup>
const email = ref("");
const submitting = ref(false);
const message = ref("");
async function subscribe() {
submitting.value = true;
try {
await $fetch("/api/subscribe", {
method: "POST",
body: { email: email.value },
});
message.value = "Check your inbox.";
} catch (error) {
message.value = error.data?.message ?? "Something went wrong.";
} finally {
submitting.value = false;
}
}
</script>
Wrapping that in useFetch would run it during SSR, on page load, before the user typed anything. Mutations belong in $fetch.
After a mutation changes data that a composable is holding, sync it:
await $fetch("/api/articles", { method: "POST", body: draft });
await refresh(); // the handle from this component
await refreshNuxtData("articles"); // by key, from anywhere
server: false Is Not a Security Boundary
An earlier version of this article listed { server: true } under "server-only fetching for sensitive data". That was wrong, and wrong in a direction that leaks data, so it gets its own section.
server: true is the default. It means "run this during server-side rendering". It does not mean "run this only on the server". On a client-side navigation the handler runs in the browser like any other, and on first load the result is serialised into the __NUXT__ payload, which is plain text in the HTML document. Anything a composable returns is readable by the user, whichever way server is set.
server: false is the option that changes behaviour, and it changes it in the opposite direction: skip this during SSR, run it only after hydration.
// Runs in the browser only. Never part of the SSR payload.
// Correct for data that is per-user and uncacheable, not for data that is secret.
const { data: notifications } = await useFetch("/api/notifications", {
server: false,
});
If a response contains an API key, an internal ID, or a field the user is not allowed to see, no combination of these options will help. Filter it in a server route and return only the fields the page is entitled to.
// server/api/profile.get.ts
export default defineEventHandler(async (event) => {
const user = await getUserFromSession(event);
// stripeCustomerId and internalNotes stay on this side.
return {
name: user.name,
email: user.email,
plan: user.plan,
};
});
status, Not pending
Both are returned and pending is still documented, so nothing is broken if you use it. status is more precise:
status | Meaning |
|---|---|
idle | Not started. You passed immediate: false and have not called execute() |
pending | In flight |
success | Resolved |
error | Rejected |
The distinction that matters is idle against pending. A boolean cannot tell them apart, so a template driven by pending renders a spinner for a request that has not been made and never will be until something calls execute().
<script setup>
const query = ref("");
const { data: results, status, execute } = await useFetch("/api/search", {
query: { q: query },
immediate: false,
});
</script>
<template>
<input v-model="query" @keyup.enter="execute()" />
<p v-if="status === 'idle'">Type a query and press enter</p>
<p v-else-if="status === 'pending'">Searching</p>
<p v-else-if="status === 'error'">Search is unavailable</p>
<ul v-else>
<li v-for="result in results" :key="result.id">{{ result.title }}</li>
</ul>
</template>
Four states, four branches. The idle branch is the one that a boolean loses.
Keys, Caching, and the Nuxt 4 Sharing Rule
The first argument to useAsyncData is the cache key. There is no separate cacheKey option, and there never was.
Keys are how the payload is looked up on hydration, how refreshNuxtData(key) finds the right entry, and how two components asking for the same thing get one request.
getCachedData gives you control over reuse across navigations:
const { data: settings } = await useAsyncData(
"settings",
() => $fetch("/api/settings"),
{
getCachedData: (key, nuxtApp) =>
nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
}
);
Return a value and the handler is skipped. Return undefined and it runs. That is the hook for "fetch this once per session" without a store.
dedupe decides what happens when a second call fires while the first is still open. The default is 'cancel', which drops the in-flight request and starts over. 'defer' keeps the first one and ignores the new call. For a search box, 'cancel' is right, because the newest query is the only one anyone wants.
Two Nuxt 4 Changes That Break Nuxt 3 Code
data Is a shallowRef Now
In Nuxt 3 the default was deep reactivity. In Nuxt 4, deep defaults to false, so mutating a property inside the returned object updates the object but triggers nothing:
const { data: user } = await useFetch("/api/user");
user.value.name = "Dulan"; // no re-render in Nuxt 4
user.value = { ...user.value, name: "Dulan" }; // re-renders
Replacing the whole value works, and a refetch replaces the whole value, so most code never notices. Code that edits a fetched object in place notices immediately. Opt back in with { deep: true } on the call, and only on the calls that need it, since the shallow default exists because deep-watching a large array is expensive.
Calls Sharing a Key Now Share Refs
Two useAsyncData("articles", ...) calls in different components return the same data, error, and status. If those calls pass conflicting transform, pick, default, deep, or getCachedData options, one of them wins and the other silently gets a shape it did not ask for. Either make the options identical, or give the calls different keys.
When These Are the Wrong Tool
These composables cache per key, for one page load, in one browser. That is the boundary.
If you need optimistic updates, retries with backoff, background revalidation, or a cache shared across routes with its own invalidation rules, that is a query library. Pinia Colada and TanStack Query both handle it, both integrate with Nuxt SSR, and the point at which you have written the third refreshNuxtData call to keep two components in agreement is the point you should have switched.
If the data changes while the user is looking at it, none of this applies at all. Polling with setInterval and refresh() works for a status field that updates every 30 seconds. It is the wrong shape for anything faster, and a WebSocket or an SSE stream is the honest answer there.
And if the data never changes, do not fetch it. Read it at build time with Nuxt Content or a build-time script and ship it as part of the bundle.
FAQs
Is useFetch just useAsyncData plus $fetch?
Close enough to reason with. useFetch(url, opts) behaves like useAsyncData(autoKey, () => $fetch(url, opts), opts). The difference is the auto-generated key and the type inference it gets from your server routes, which is worth more than it sounds when the API changes.
Why does my data fetch twice?
Almost always a key mismatch between server and client. useFetch derives its key from the URL and options, so a URL containing anything unstable, a timestamp, a random value, a client-only ref, produces a different key at hydration, misses the payload, and refetches. Pass an explicit key to pin it.
Should I await these composables?
Await the blocking ones so Nuxt can suspend the component while the request runs. Do not await the lazy ones, since there is nothing to wait for and awaiting suggests otherwise to the next person reading it.
How do I pass cookies to an external API during SSR?
The server render does not automatically forward the browser's headers. Read them explicitly:
const headers = useRequestHeaders(["cookie"]);
const { data } = await useFetch("https://api.example.com/me", { headers });
Can I call these outside a component?
Only inside a setup function, a plugin, or a route middleware, because they need the Nuxt context to reach the payload. Calling one inside a click handler or a setTimeout throws a context error. Use $fetch there.