useFetch vs useAsyncData vs $fetch: Understanding Nuxt Data Fetching
Why Nuxt offers three ways to make HTTP requests, when to use each one, and the pitfalls waiting for the unprepared.
Why there are three options
Nuxt offers three ways to make HTTP requests: $fetch, useAsyncData, and useFetch. This isn't a design mistake — each option serves a different layer.
$fetch is the raw HTTP client. useAsyncData is the SSR-aware wrapper composable. useFetch combines both into a simpler API — with pitfalls you need to know about.
The confusion comes from the fact that all three ultimately make HTTP requests. The difference isn't in the "what" but in the "how and when": Who executes the request? When? How are the data transferred between server and client?
$fetch — the bare HTTP client
$fetch is Nuxt's own fetch wrapper, built on ofetch. It does exactly what you'd expect:
const data = await $fetch('/api/posts')
On the server, $fetch resolves the request directly — without an actual HTTP roundtrip when the endpoint runs in the same Nitro process. On the client, it makes a normal HTTP request.
The problem: $fetch doesn't know about the SSR context. If you call it directly in <script setup>, it runs on the server and on the client. The data gets fetched twice — once during server rendering, once during hydration.
// WRONG: runs on server + client = double request
const data = await $fetch('/api/posts')
// RIGHT: wrap in useAsyncData
const { data } = await useAsyncData('posts', () => $fetch('/api/posts'))
When $fetch alone makes sense: in event handlers, in server routes (server/api/), inside useAsyncData callbacks, or anywhere you're deliberately operating outside the SSR lifecycle.
useAsyncData — SSR-aware, deduplicated, cached
useAsyncData is the heart of Nuxt data fetching. It does three things:
- Executes the async handler only on the server (with SSR)
- Serializes the result into the HTML payload (
nuxtApp.payload) - Hydrates the client with that data — without a second request
The signature:
const { data, status, error, refresh, execute } = await useAsyncData(
'unique-key',
() => $fetch('/api/posts'),
{ /* options */ }
)
The key is critical — more on that shortly.
data is a Ref<T | null>. status is 'idle' | 'pending' | 'success' | 'error'. error holds any fetch errors as Ref<FetchError | null>.
const { data: posts, status, error } = await useAsyncData(
'blog-posts',
() => $fetch<Post[]>('/api/posts')
)
if (error.value) {
console.error('Failed to load:', error.value.message)
}
useAsyncData isn't a request shortcut — it's a lifecycle manager for async data in the SSR context.
useFetch — syntactic sugar with pitfalls
useFetch is nothing but useAsyncData + $fetch in one composable:
// These two are equivalent:
const { data } = useFetch('/api/posts')
const { data } = useAsyncData(
'/api/posts', // URL is used as the key
() => $fetch('/api/posts')
)
With one important difference: useFetch automatically generates the key from the URL. That sounds convenient, but it's a common source of bugs with dynamic URLs.
// WRONG: URL is static — doesn't react to changes
const { data } = useFetch(`/api/posts/${route.params.id}`)
// RIGHT: pass URL as a function so it becomes reactive
const { data } = useFetch(() => `/api/posts/${route.params.id}`)
When you pass the URL as a string literal, it's evaluated at call time and never updated afterwards. If route.params.id changes, nothing happens — data stays at the first value.
useFetch supports the same options as useAsyncData — watch, transform, pick, getCachedData, lazy, server, and so on.
The key: deduplication and why it must be correct
useAsyncData and useFetch deduplicate requests by key. Two components or composables with the same key share the same data and don't trigger a second request.
That's good for performance — but a wrong key breaks this deduplication silently.
// BUG: both calls have the same key but load different posts
const { data: post1 } = useAsyncData('post', () => $fetch('/api/posts/1'))
const { data: post2 } = useAsyncData('post', () => $fetch('/api/posts/2'))
// post2.value === post1.value — post2 never gets its own data
The key must include all variables that uniquely identify the data:
const id = computed(() => route.params.id)
const { data: post } = useAsyncData(
`post-${id.value}`, // key includes the ID
() => $fetch(`/api/posts/${id.value}`)
)
With useFetch, the URL is used as the key — which is why it's important to include dynamic values directly in the URL function, not hidden away as separate variables.
Hydration: why data isn't re-fetched on the client
This is the key mechanism that ties everything together. With SSR, the following happens:
- Server executes the
useAsyncDatahandler - Data is serialized into
nuxtApp.payload(embedded as JSON in the HTML) - Browser loads the HTML — including the payload
- Vue hydrates:
useAsyncDatafinds the data in the payload and doesn't trigger a new request
This means: on the client, no second request is made. The data is already in the HTML.
This behavior can be deliberately overridden:
const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
server: false, // only run on the client — skip SSR
lazy: true, // don't wait for the handler — page renders immediately
})
server: false makes sense for data that has no SEO impact — for example, user-specific content behind a login.
lazy: true makes sense when the page should render immediately and data loads afterwards — similar to a skeleton loading pattern.
watch, refresh(), and navigation refetching
useAsyncData reacts to reactive dependencies through the watch option:
const filter = ref('all')
const { data, refresh } = useAsyncData(
'posts',
() => $fetch('/api/posts', { query: { filter: filter.value } }),
{ watch: [filter] } // automatically reload when filter changes
)
Alternatively, call refresh() manually — for example after a user action:
async function handleFilterChange(newFilter: string) {
filter.value = newFilter
await refresh()
}
By default, useAsyncData calls are not re-executed on navigation when the key stays the same. That's intentional — Nuxt uses the cached value. To reload on every navigation, include the route in watch, or call execute() directly in a navigation guard.
getCachedData for SWR patterns
getCachedData gives you finer control over caching behavior at the app layer. The function receives the key and the nuxtApp instance, and can return cached data before the handler runs:
const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
getCachedData(key, nuxtApp) {
// Return cached data from payload if available
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
}
})
A real stale-while-revalidate pattern — show data immediately (cached), update in the background:
const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
getCachedData(key, nuxtApp) {
const cached = nuxtApp.payload.data[key]
if (cached) {
// Return cached data immediately — the fetch still runs
return cached
}
}
})
getCachedData is the right tool for SWR at the Nuxt app layer — not to be confused with Nitro-level caching through routeRules.
transform and pick — keeping the payload small
The Nuxt payload is embedded as JSON in the initial HTML. Large API responses slow down download and parsing.
transform lets you reshape the response before it's stored in the payload:
const { data: titles } = useAsyncData(
'post-titles',
() => $fetch<Post[]>('/api/posts'),
{
transform(posts) {
// Keep only titles — the rest never makes it into the payload
return posts.map(p => ({ id: p.id, title: p.title }))
}
}
)
pick does the same thing declaratively at the property level:
const { data: posts } = useFetch('/api/posts', {
pick: ['id', 'title', 'date'] // only these fields in the payload
})
Both options reduce payload size and improve time to First Contentful Paint — especially relevant on pages with many large data objects.
When server-only fetching is the right choice
Sometimes data should only be fetched on the server — for example when API keys are involved that must never reach the client.
const { data } = useAsyncData('protected-data', async () => {
// This code runs only on the server
// The API key is safe — never embedded in the client bundle
return await $fetch('https://api.example.com/data', {
headers: {
Authorization: `Bearer ${process.env.SECRET_API_KEY}`
}
})
})
With server: true (the default) and without lazy: true, the handler runs exclusively on the server. The client hydrates with the serialized data — the API key appears nowhere in the client code.
When you need to reload data after user interaction, use $fetch in an event handler — deliberately outside the SSR lifecycle, knowing that this request happens only in the browser.
async function loadMore() {
// In event handlers: $fetch directly, no useAsyncData
const more = await $fetch('/api/posts', {
query: { page: currentPage.value + 1 }
})
posts.value.push(...more)
currentPage.value++
}
The rule of thumb: useAsyncData/useFetch for initial loading with SSR support, $fetch for everything that happens after user interaction.
Further Reading
Alexander Lichter — Nuxt core team member and one of the best explainers around the framework — covers this topic in depth in this video:

