Data Fetching
TL;DR
Nuxt provides three tools: useFetch (the default for component data), useAsyncData (custom async logic), and $fetch (raw requests in event handlers and server code). useFetch handles SSR deduplication, payload serialization, caching, and reactive refetching automatically. Use $fetch only in event handlers or server routes where you don’t need reactive state.
Mental Model
The decision tree:
Need reactive data in a component template?
├── Yes → Is the data source a URL?
│ ├── Yes → useFetch(url)
│ └── No → useAsyncData(key, () => myAsyncFn())
└── No → $fetch(url) (event handlers, server routes, fire-and-forget)
The key insight: useFetch runs on the server during SSR, serializes the result into the HTML payload, and skips re-fetching on client hydration. The client reads from the payload — zero duplicate requests. On subsequent client-side navigations, it fetches fresh data from the client.
First page load (SSR):
Server: useFetch → HTTP request → data → render HTML + embed in payload
Client: hydrate → read from payload → no request
Client navigation:
Client: useFetch → HTTP request → data → re-render component
useFetch vs useAsyncData vs $fetch
| Â | useFetch | useAsyncData | $fetch |
|---|---|---|---|
| SSR payload dedup | âś“ | âś“ | âś— |
| Reactive state | âś“ | âś“ | âś— |
| Auto-key generation | âś“ | manual key | n/a |
| Caching (getCachedData) | âś“ | âś“ | âś— |
| Use in templates | âś“ | âś“ | âś— (no reactive ref) |
| Use in event handlers | âś— | âś— | âś“ |
| Use in server routes | âś— | âś— | âś“ |
useFetch(url, options) — sugar for useAsyncData(key, () => $fetch(url, options)). Use it for straightforward API calls.
useAsyncData(key, handler) — when your data comes from something other than a URL (a composable, multiple requests combined, complex logic). You provide the key and the fetcher.
$fetch(url) — raw ofetch (Nuxt’s HTTP client). No SSR magic, no reactive state. Use in @click handlers, form submissions, server routes, or anywhere you don’t need the composable wrapper.
Server vs Client Execution
<script setup lang="ts">
// This runs on SERVER during SSR, then on CLIENT during navigation
const { data, error, pending } = await useFetch('/api/users')
</script>
During SSR:
useFetchcalls$fetch('/api/users')on the server- Nitro intercepts the request internally (no actual HTTP call — it calls the handler directly)
- Result is serialized into the HTML payload (
<script>window.__NUXT__=...) - Component renders with data
During hydration:
- Client reads data from payload
- No network request fires
- Component hydrates with the same data
During client navigation:
useFetchfires a real HTTP request to/api/users- Returns response, updates reactive state
The Return Object
const {
data, // Ref<T | null> — the response data
error, // Ref<Error | null> — error if request failed
pending, // Ref<boolean> — true while loading
status, // Ref<'idle' | 'pending' | 'success' | 'error'>
refresh, // () => Promise<void> — refetch (uses cache key)
execute, // () => Promise<void> — same as refresh
clear, // () => void — clear data and error
} = useFetch('/api/users')
All values are refs — reactive and template-ready:
<template>
<div v-if="pending">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in data" :key="user.id">{{ user.name }}</li>
</ul>
</template>
Caching with getCachedData
By default, useFetch doesn’t cache across navigations — every navigation triggers a fresh request. To enable caching:
<script setup lang="ts">
const { data } = await useFetch('/api/users', {
getCachedData(key, nuxtApp) {
// Return cached data if available and not stale
const cached = nuxtApp.payload.data[key] || nuxtApp.static.data[key]
if (!cached) return undefined // undefined = fetch again
return cached // Return cached value = skip fetch
},
})
</script>
Stale-while-revalidate pattern:
<script setup lang="ts">
const STALE_TIME = 30_000 // 30 seconds
const { data } = await useFetch('/api/users', {
getCachedData(key, nuxtApp) {
const cached = nuxtApp.payload.data[key]
if (!cached) return undefined
// Check if data is stale
const fetchedAt = nuxtApp.payload._fetchedAt?.[key]
if (fetchedAt && Date.now() - fetchedAt > STALE_TIME) {
return undefined // Stale — refetch
}
return cached // Fresh — use cache
},
onResponse({ response }) {
// Track when data was fetched
const nuxtApp = useNuxtApp()
nuxtApp.payload._fetchedAt = nuxtApp.payload._fetchedAt || {}
nuxtApp.payload._fetchedAt[key] = Date.now()
},
})
</script>
This gives you instant back-navigation (show cached data) while still refreshing stale data.
Watch and Reactive Refetching
useFetch can reactively refetch when dependencies change:
<script setup lang="ts">
const page = ref(1)
const search = ref('')
// Refetches whenever page or search changes
const { data } = await useFetch('/api/users', {
query: { page, search }, // Reactive query params
watch: [page, search], // Explicit watch sources (optional when using reactive query)
})
</script>
<template>
<input v-model="search" placeholder="Search..." />
<button @click="page++">Next Page</button>
<UserList :users="data" />
</template>
When you pass reactive values to query, useFetch automatically watches them and refetches. The watch option is for cases where you need to trigger a refetch based on values NOT in the URL (e.g., a filter stored in a composable).
Lazy Mode
By default, useFetch blocks navigation — the new page doesn’t render until data arrives. Lazy mode changes this:
<script setup lang="ts">
// Non-blocking — page renders immediately, data arrives later
const { data, pending } = useLazyFetch('/api/slow-endpoint')
// or: useFetch('/api/slow-endpoint', { lazy: true })
</script>
<template>
<SkeletonLoader v-if="pending" />
<Content v-else :data="data" />
</template>
Use lazy mode for:
- Secondary data that shouldn’t block page render
- Below-the-fold content
- Data that’s nice-to-have but not critical
Keep blocking mode (default) for:
- Primary page content
- Data needed for the page to make sense
- SEO-critical content (lazy data isn’t in the initial HTML during SSR)
Note: useLazyFetch still runs on the server during SSR. It just doesn’t block the response. The HTML ships with pending: true state and the client completes the fetch.
Transform and Pick
transform — reshape data before it hits your component:
<script setup lang="ts">
const { data: users } = await useFetch('/api/users', {
transform: (response) => {
// API returns { data: [...], meta: {...} }
// Component only needs the array
return response.data.map(user => ({
id: user.id,
displayName: `${user.firstName} ${user.lastName}`,
avatar: user.profileImage?.url ?? '/default-avatar.png',
}))
},
})
</script>
Transform runs after the fetch, before caching. The cached/payload value is the transformed result — keeps your payload small.
pick — select specific top-level keys from the response:
<script setup lang="ts">
// API returns { id, name, email, address, phone, company, ... }
// You only need id and name
const { data } = await useFetch('/api/users/1', {
pick: ['id', 'name'],
})
// data.value = { id: 1, name: 'John' }
pick only works on top-level keys. For deeper extraction, use transform.
$fetch in Event Handlers
Never use useFetch inside event handlers or callbacks — it’s a composable that must run in setup context:
<script setup lang="ts">
// âś“ useFetch for reactive data
const { data: users, refresh } = await useFetch('/api/users')
// âś“ $fetch for mutations/actions
const deleteUser = async (id: number) => {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
refresh() // Refresh the list after deletion
}
const createUser = async (form: UserForm) => {
const newUser = await $fetch('/api/users', {
method: 'POST',
body: form,
})
// Option 1: refresh the list
refresh()
// Option 2: optimistically update
users.value = [...users.value!, newUser]
}
</script>
useAsyncData for Custom Logic
When you need more than a simple URL fetch:
<script setup lang="ts">
// Combine multiple requests
const { data: dashboardData } = await useAsyncData('dashboard', async () => {
const [users, posts, stats] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/posts'),
$fetch('/api/stats'),
])
return { users, posts, stats }
})
// Use a third-party SDK
const { data: products } = await useAsyncData('products', () => {
return stripeClient.products.list({ limit: 10 })
})
The key (first argument) must be unique across your app. Nuxt uses it for payload serialization and deduplication.
Request Deduplication
During SSR, if two components call useFetch('/api/users') with the same URL and options, Nuxt makes one request and shares the result. The key is auto-generated from the URL + options. This prevents N+1 fetch waterfalls.
For useAsyncData, you control the key — same key = same data shared:
<!-- ComponentA.vue -->
<script setup>
const { data } = await useAsyncData('shared-users', () => $fetch('/api/users'))
</script>
<!-- ComponentB.vue -->
<script setup>
// Same key — reuses ComponentA's request during SSR
const { data } = await useAsyncData('shared-users', () => $fetch('/api/users'))
</script>
Walkthrough
Open the 04-data-fetching starter:
cd starters/04-data-fetching
npm install
npm run dev
- Check the server API. Look at
server/api/— these provide the data endpoints. - Inspect useFetch usage. Open the page components — see how they fetch and display data.
- View the payload. View page source — find
window.__NUXT__script. That’s the serialized SSR data. - Test deduplication. Open Network tab, hard-refresh the page. Count the API requests made during SSR vs what appears in payload.
- Add getCachedData. Implement the stale-while-revalidate pattern. Navigate away and back — verify the data loads instantly from cache.
- Add a mutation. Create a form that uses
$fetchwith POST, then callsrefresh()to update the list.
Gotchas
- Don’t use
useFetchin event handlers. It’s a composable — must run in setup context (top-level<script setup>). Use$fetchfor mutations and actions triggered by user interaction. - Don’t use
useFetchconditionally. Like all Vue composables, it must run on every render. Can’t be insideifblocks or after early returns. Use theimmediate: falseoption +execute()if you need conditional fetching. - Watch sources must be reactive.
watch: [page]only works ifpageis areforcomputed. Raw values won’t trigger refetches. getCachedDatareturningundefinedmeans “fetch again.” Any other value (evennull) means “use this as the data.” Be explicit.pickonly works on top-level keys.pick: ['user.name']doesn’t work. Usetransformfor nested extraction.- Key deduplication. Same URL + same options = same cache key. If you need different data from the same URL (different auth context, different behavior), provide a unique
keyoption. - Hydration mismatch with conditional useFetch. If
useFetchruns on the server but not on the client (or vice versa), you’ll get hydration errors. Ensure the composable runs in both environments consistently. useFetchto your own API is free during SSR. When youuseFetch('/api/users')during SSR, Nuxt calls the handler directly — no HTTP round-trip. This is why internal API calls are fast during SSR.- Payload size. Everything
useFetchreturns during SSR goes into the HTML payload. Fetching 1000 records means 1000 records in your HTML source. Usetransformorpickto trim it. Or paginate.
Exercise
- Build a paginated user list:
useFetch('/api/users', { query: { page, limit: 10 } })- Page ref that increments/decrements
- Loading skeleton while
pendingis true - Error message when
erroris set
- Build a user detail page (
/users/[id]):useFetchwith the dynamic param- Implement
getCachedDatathat returns stale data for 30 seconds - Navigate away and back — verify instant load from cache
- Add a “Create User” form:
- Use
$fetchwith POST on submit - After success, call
refresh()on the list
- Use
- Add a “Refresh” button that calls
refresh()to manually refetch the list.