Data Fetching in Nuxt
useFetch and useAsyncData fetch once on the server and hand the result to the browser in the payload.
const { data, status, error, refresh } = await useFetch<T>(url, options)Under server rendering a component's setup runs twice: once on the server to produce HTML, once in the browser during hydration. A plain fetch or $fetch in setup therefore runs twice, so the same request is paid for again as soon as the page loads.
The two composables
useFetch(url, options) takes a URL and fetches it. useAsyncData(key, handler, options) takes an explicit key and any async function, so use it when the work is not a single request. Both are awaited in setup and return the same shape: data, status ('idle', 'pending', 'success' or 'error'), error, plus refresh, execute and clear functions.
They run on the server, then put the result into the Nuxt payload, a JSON blob embedded in the HTML and read back during hydration. The browser reuses that value instead of refetching. This is also why the data must be JSON-serialisable: class instances, functions and symbols do not survive the trip.
Options worth knowing
server: false skips the server and fetches only in the browser. lazy: true lets navigation finish without waiting. immediate: false waits for you to call execute. default supplies a value before the first result, transform reshapes the response, pick keeps named fields only, watch refetches when a reactive source changes, and getCachedData lets you answer from the payload yourself.
The trap is a URL built as a plain template string: it is evaluated once, so the request never follows the route. Pass a function or a computed instead.
Example
<!-- app/pages/products/index.vue -->
<script setup lang="ts">
type Product = { id: number; name: string; price: number }
const { data, status, error, refresh } = await useFetch<Product[]>(
'/api/products',
{ default: () => [] },
)
</script>
<template>
<section>
<h1>Products</h1>
<p v-if="status === 'pending'">Loading products</p>
<p v-else-if="error">
Could not load products.
<button type="button" @click="refresh()">Try again</button>
</p>
<ul v-else>
<li v-for="product in data" :key="product.id">
{{ product.name }} - {{ product.price }}
</li>
</ul>
</section>
</template>
<style scoped>
ul {
list-style: none;
padding: 0;
}
</style>When to use it
- Rendering a list page on the server so its contents are in the HTML, without the browser refetching it.
- Keeping a search request in step with an input ref without writing a watcher.
- Reusing data a previous route already loaded instead of hitting the API again on back navigation.
More examples
Refetching when an argument changes
`useAsyncData` is the right choice here because the request is assembled rather than a fixed URL. `watch: [term]` reruns the handler whenever the ref changes, so the caller never calls `refresh` by hand. `transform` runs before the value is stored, which means the trimmed array is what goes into the payload rather than the full response.
// app/composables/useProductSearch.ts
import type { Ref } from 'vue'
type SearchResponse = { items: { id: number; name: string }[] }
export function useProductSearch(term: Ref<string>) {
return useAsyncData(
'product-search',
() => $fetch<SearchResponse>('/api/search', { query: { q: term.value } }),
{
watch: [term],
transform: (res) => res.items.map((item) => item.name),
default: () => [],
},
)
}
// term.value = 'mug', API returns { items: [{ id: 1, name: 'Mug' }] }
// data.value
// Output: ['Mug']Skipping the server for a private widget
`server: false` is for data that is per-user and not worth rendering on the server, or that needs a browser-only credential. The server sends the placeholder markup, the request starts after hydration. On the server the status is `'idle'`, not `'pending'`, because the request never starts there; the server render and the client's first render both show the `default` value, so there is no hydration mismatch.
<!-- app/components/UnreadCount.vue -->
<script setup lang="ts">
const { data, status } = await useFetch<{ unread: number }>('/api/inbox', {
server: false,
default: () => ({ unread: 0 }),
})
</script>
<template>
<span v-if="status === 'pending'">Checking inbox</span>
<span v-else>{{ data.unread }} unread</span>
</template>
<style scoped>
span {
font-weight: 600;
}
</style>Answering from the payload
`getCachedData` receives the key and the Nuxt app instance, and returning a value means no request is made. Reading `nuxtApp.payload.data[key]` reuses whatever the server already fetched under that key, which is useful when the user navigates back to a page in the same session. Returning `undefined` falls through to the handler.
// app/composables/useCachedPost.ts
type Post = { slug: string; title: string }
export function useCachedPost(slug: string) {
return useAsyncData(
() => `post-${slug}`,
() => $fetch<Post>(`/api/posts/${slug}`),
{
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
},
)
}
Discussion