useFetch & useAsyncData
Nuxt 3 provides two primary composables for fetching data: useFetch and useAsyncData. Both are SSR-aware, meaning they fetch data on the server during initial render, serialize the result into the HTML payload, and hydrate it on the client without a redundant second request. This is the foundation of Nuxt's data layer.
useFetch: The Primary Composable
useFetch is the most common way to fetch data in Nuxt. It wraps Nuxt's $fetch utility (which itself wraps ofetch) and handles caching, deduplication, SSR payload transfer, and reactive URL/parameter watching.
<script setup lang="ts">
const { data, status, error, refresh } = await useFetch('/api/products')
</script>
<template>
<div>
<p v-if="status === 'pending'">Loading products...</p>
<p v-else-if="error">Failed to load: {{ error.message }}</p>
<ul v-else>
<li v-for="product in data" :key="product.id">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>
Return Values
useFetch returns a reactive object with these properties:
const {
data, // Ref<T | null> -- the response data
status, // Ref<'idle' | 'pending' | 'success' | 'error'>
error, // Ref<Error | null>
refresh, // () => Promise<void> -- re-executes the fetch
execute, // () => Promise<void> -- same as refresh
clear, // () => void -- clears data and resets state
} = await useFetch('/api/endpoint')
Typed Responses
Type the response to get full TypeScript support:
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
category: string
}
const { data: products } = await useFetch<Product[]>('/api/products')
// products is Ref<Product[] | null>
</script>
Request Options
useFetch supports all $fetch options: method, headers, body, query parameters, and more:
<script setup lang="ts">
const category = ref('electronics')
const { data: products } = await useFetch('/api/products', {
method: 'GET',
query: { category, limit: 20 },
headers: {
'X-Custom-Header': 'value'
}
})
</script>
When query contains reactive values (refs), useFetch automatically re-fetches when they change.
Transforming Responses
Use the transform option to reshape the API response before it reaches your component:
<script setup lang="ts">
const { data: productNames } = await useFetch('/api/products', {
transform: (products) => products.map((p) => p.name)
})
// productNames is Ref<string[] | null>
</script>
The transform also reduces the payload size transferred from server to client, since only the transformed data is serialized.
useAsyncData: Lower-Level Control
useAsyncData is the lower-level composable that useFetch is built on. Use it when your data source is not a simple HTTP endpoint -- for example, when you need to combine multiple API calls, query a database directly, or run custom async logic.
<script setup lang="ts">
const { data: dashboard } = await useAsyncData('dashboard', async () => {
const [stats, recentOrders, topProducts] = await Promise.all([
$fetch('/api/stats'),
$fetch('/api/orders', { query: { limit: 5 } }),
$fetch('/api/products', { query: { sort: 'popular', limit: 5 } })
])
return { stats, recentOrders, topProducts }
})
</script>
<template>
<div>
<StatsPanel :stats="dashboard?.stats" />
<RecentOrders :orders="dashboard?.recentOrders" />
<TopProducts :products="dashboard?.topProducts" />
</div>
</template>
Key Differences
| Feature | useFetch |
useAsyncData |
|---|---|---|
| Data source | HTTP endpoint via $fetch |
Any async function |
| Key | Auto-generated from URL | Must be provided manually |
| Watching | Watches reactive query params | Watches via explicit watch option |
| Use case | Standard API calls | Complex or multi-source fetching |
useFetch('/api/products') is essentially shorthand for:
useAsyncData('products', () => $fetch('/api/products'))
Deduplication
Both composables deduplicate requests using a key. If two components on the same page call useFetch('/api/user'), the request fires only once:
<!-- components/UserAvatar.vue -->
<script setup lang="ts">
const { data: user } = await useFetch('/api/user')
</script>
<!-- components/UserGreeting.vue -->
<script setup lang="ts">
// Same URL = same key = no duplicate request
const { data: user } = await useFetch('/api/user')
</script>
For useAsyncData, provide the same string key to enable deduplication:
// Both resolve from the same cached data
const { data } = await useAsyncData('current-user', () => $fetch('/api/user'))
Lazy Fetching
By default, useFetch and useAsyncData block navigation until data is loaded. Lazy mode lets the page render immediately and loads data in the background:
<script setup lang="ts">
const { data: comments, status } = await useFetch('/api/comments', {
lazy: true
})
</script>
<template>
<div>
<h2>Comments</h2>
<p v-if="status === 'pending'">Loading comments...</p>
<div v-else>
<CommentCard v-for="comment in comments" :key="comment.id" :comment="comment" />
</div>
</div>
</template>
Nuxt also provides useLazyFetch and useLazyAsyncData as convenience wrappers:
// These are equivalent
const result1 = await useFetch('/api/data', { lazy: true })
const result2 = await useLazyFetch('/api/data')
Use lazy fetching for non-critical data (comments, recommendations, analytics) that should not delay page load.
Refresh & Execute
Both composables return refresh and execute functions that re-run the fetch:
<script setup lang="ts">
const { data: notifications, refresh } = await useFetch('/api/notifications')
// Manual refresh after a user action
async function markAllRead() {
await $fetch('/api/notifications/mark-read', { method: 'POST' })
await refresh()
}
</script>
<template>
<div>
<button @click="refresh">Refresh</button>
<button @click="markAllRead">Mark All Read</button>
<NotificationList :items="notifications" />
</div>
</template>
Watching Reactive Sources
useFetch automatically re-fetches when reactive query parameters change. For useAsyncData, use the watch option:
<script setup lang="ts">
const page = ref(1)
const search = ref('')
const { data: results } = await useAsyncData(
'search-results',
() => $fetch('/api/search', { query: { q: search.value, page: page.value } }),
{ watch: [page, search] }
)
</script>
Server/Client Rendering & The Payload
The fundamental value of these composables is the SSR handoff. Here is what happens during a full page load:
1. Browser requests /products
2. Server runs useFetch('/api/products') -> fetches data
3. Server renders the HTML with the data already embedded
4. Server serializes the data into a <script> tag (the payload)
5. Browser receives the HTML (already showing products)
6. Client hydrates, reads the payload, skips the redundant fetch
7. Component is interactive with data already in place
On client-side navigation (via NuxtLink), the data is fetched from the client directly because the server is not involved in SPA transitions.
This pattern means:
- The initial page load is fast and SEO-friendly because search engines see the rendered HTML.
- No loading spinner flashes on the first visit.
- Subsequent navigations still behave like a normal SPA.
Practical Example: Product Listing with Filters
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
category: string
}
const selectedCategory = ref('all')
const sortBy = ref('name')
const { data: products, status } = await useFetch<Product[]>('/api/products', {
query: {
category: selectedCategory,
sort: sortBy
},
transform: (raw) => {
return raw.map((p) => ({
...p,
priceFormatted: `$${p.price.toFixed(2)}`
}))
}
})
</script>
<template>
<div>
<div class="filters">
<select v-model="selectedCategory">
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select v-model="sortBy">
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</div>
<p v-if="status === 'pending'">Loading...</p>
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }} - {{ product.priceFormatted }}
</li>
</ul>
</div>
</template>
Changing selectedCategory or sortBy triggers an automatic re-fetch because useFetch watches reactive query parameters.
Common Pitfalls
- Calling
useFetchoutside<script setup>. These composables rely on the Nuxt component context. Calling them in a regular function orsetTimeoutthrows errors. Always call them at the top level of<script setup>or inside another composable. - Forgetting that
datacan benull. Before the fetch resolves (or if it fails),data.valueisnull. Always handle the null case in templates and computed properties. - Not providing a key for
useAsyncData. Without a unique key, deduplication and caching cannot work correctly. If two components use the same key with different fetch logic, one will overwrite the other. - Using
$fetchdirectly in components. Calling$fetchwithout wrapping it inuseAsyncDatameans no SSR payload transfer, no deduplication, and a redundant client-side request after hydration. Always use the composables for component data. - Blocking navigation with non-essential data. Large datasets or slow endpoints delay page transitions when fetched eagerly. Use
lazy: truefor data that is not critical for the initial render.
Key Takeaways
useFetchis the standard composable for HTTP data fetching. It wraps$fetchwith SSR payload transfer, deduplication, and reactive watching.useAsyncDatais the lower-level alternative for custom async logic, multiple concurrent requests, or non-HTTP data sources.- Both composables fetch on the server, serialize the result into the page payload, and skip the redundant client-side request during hydration.
- Lazy fetching (
lazy: trueoruseLazyFetch) renders the page immediately and loads data in the background. - Reactive query parameters trigger automatic re-fetching. Use
refresh()for manual re-fetching after user actions. - Always use these composables instead of raw
$fetchin components to get the full benefit of Nuxt's SSR data handling.