Nuxt 4: $fetch, useFetch and useAsyncData - Complete Data Fetching Guide
Nuxt gives you three tools for fetching data, and they sit at different levels of abstraction. Mixing them up is the most common mistake I see in Nuxt codebases, so let's be precise about what each one does.
$fetch is the lowest level. It is a raw HTTP client, Nuxt's souped-up version of the browser fetch API. You give it a URL, it makes a request. It has no concept of SSR state transfer.
useAsyncData is the core state primitive. It wraps any async operation, tracks its lifecycle, and serializes the result into Nuxt's payload so the client receives it during hydration without re-requesting. This is what prevents double fetching.
useFetch is convenience sugar on top of both. Under the hood it calls useAsyncData with $fetch, auto-generates a cache key from the URL and options, and gives you clean one-liner syntax. It only works for single-endpoint GET requests.
When to use which
The decision is architectural, not stylistic.
useFetch: simple GET requests with SSR
Reach for useFetch when you need data from a single endpoint, you want SSR support, and you do not want to think about cache keys. It gives you the cleanest syntax for the most common case: loading data for a page.
Getting a list of articles:
// app/pages/posts.vue
<script setup lang="ts">
// Takes care of making the request once (on the server) and transferring the state.
const { data: posts } = await useFetch('/api/posts')
</script>useAsyncData: anything that is not a plain GET
You need useAsyncData when your async operation is more than a single HTTP call. It wraps any promise, not just $fetch.
Two cases where it is mandatory:
Parallel calls. When you need to orchestrate multiple requests and combine the results:
const { data } = await useAsyncData("dashboard", async () => {
const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]);
return { user, stats };
});Non-HTTP data sources. When you are calling something other than $fetch, like a GraphQL client or a database ORM:
// myGetFunction is a custom function from your data client
const { data } = await useAsyncData("users", () => myGetFunction("users"));$fetch: mutations and client-driven actions
Use $fetch for anything triggered by user interaction: form submissions, button clicks, deletions. State does not need to transfer from server to client in these cases, so the useAsyncData machinery is dead weight.
Mutations (POST, PUT, DELETE). Sending form data:
// Used inside an event handler
async function submitForm() {
await $fetch("/api/contact", {
method: "POST",
body: formData.value
});
}Internal server route calls. Inside Nuxt's API routes (server/api), $fetch skips the HTTP round-trip entirely and calls the handler function directly. That is measurably faster than a real HTTP request.
Strengths and trade-offs
$fetch wins on simplicity and directness. It is the right tool for client-side mutations. But use it for initial data loading in a server-rendered component and the browser will repeat the request during hydration. That is the double-fetch problem, and it wastes bandwidth and adds latency.
useAsyncData gives you total control. It wraps any async logic, guarantees single-fetch SSR behavior, and returns a full reactive state object: data, pending, error, and status (a string: "idle", "pending", "success", or "error"). In Nuxt 4, data is a shallowRef by default, which means deep reactivity is off. For large datasets this cuts CPU overhead significantly. The cost is verbosity: you must define the handler and provide a unique key.
useFetch gives you the best developer experience for the simple case. It infers response types from server routes, auto-generates keys, and keeps your setup function readable. But it only handles single-endpoint calls, and using it for mutations in setup is a bad idea since there is no state to hydrate.
Client-side vs server-side context
The core question is whether you need state transfer for universal rendering.
Server-side (SSR) and universal rendering
When you need data in the initial HTML for SEO or performance, you must use tools that handle payload transfer.
For initial GET requests in pages or components, use useFetch or useAsyncData. They fetch once on the server and hydrate on the client. No duplicate requests.
For internal API route calls from server code, use $fetch. Nuxt intercepts the call and runs the handler directly, skipping the HTTP layer entirely.
One warning worth repeating: if you use bare $fetch for initial data loading in a server-rendered <script setup>, the client will fire the same request again during hydration. You get double fetching every time.
Client-side
After hydration or in response to user events, the rules change.
For mutations triggered by user actions (POST, PUT, DELETE), use $fetch directly. There is no server state to transfer, so useAsyncData is unnecessary overhead.
For non-SEO data or client-dependent data like geolocation, use useFetch or useAsyncData with server: false. This skips the server execution and defers fetching until the client takes over.
To refresh existing data or run a deferred function, use the refresh() or execute() methods that useFetch and useAsyncData return. No need to drop down to $fetch.
Think of
useFetchanduseAsyncDataas a sealed envelope traveling from server to client. The server fills it with data and seals it shut. The client opens the envelope, no extra trip needed.$fetchis a phone call. If the server makes the call, the client has to dial the same number again to get its own copy.
Practical examples
Dashboard with parallel loading and detailed state
// pages/dashboard.vue
<script setup lang="ts">
// Optimized loading with detailed state management
const {
data: dashboard,
pending,
error,
refresh
} = await useAsyncData('dashboard', async () => {
// Parallel calls for better performance
const [user, stats, notifications] = await Promise.all([
$fetch('/api/user/profile'),
$fetch('/api/analytics/stats'),
$fetch('/api/notifications/unread')
])
return {
user,
stats,
notifications
}
}, {
// Advanced options
server: true, // Execute on server
lazy: false, // Wait before rendering
default: () => ({ // Default value while loading
user: null,
stats: { views: 0, users: 0 },
notifications: []
})
})
// Manual refresh with debouncing
const debouncedRefresh = useDebounceFn(refresh, 300)
// Watch for automatic refresh
watch(() => route.path, debouncedRefresh)
</script>Form submission with error handling
// components/ContactForm.vue
<script setup lang="ts">
const formData = ref({
name: '',
email: '',
message: ''
})
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
async function submitForm() {
isSubmitting.value = true
submitError.value = null
try {
const response = await $fetch('/api/contact', {
method: 'POST',
body: formData.value,
// Additional options
timeout: 10000,
retry: 2,
retryDelay: 1000
})
// Success handling
await navigateTo('/thank-you')
} catch (error) {
if (error.data?.message) {
submitError.value = error.data.message
} else {
submitError.value = 'Error submitting form. Please try again.'
}
} finally {
isSubmitting.value = false
}
}
</script>Data transformation with useFetch
// pages/products/[id].vue
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
description: string
category: string
}
interface ProductWithDetails extends Product {
formattedPrice: string
isInStock: boolean
relatedProducts: Product[]
}
const { data: product, pending } = await useFetch<ProductWithDetails>(
`/api/products/${route.params.id}`,
{
// Data transformation
transform: (data: Product) => ({
...data,
formattedPrice: new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(data.price),
isInStock: data.price > 0,
relatedProducts: [] // Will be filled later
}),
// Advanced caching
server: true,
key: `product-${route.params.id}`,
// Cache options
getCachedData: (key) => {
// Custom cache logic
return nuxtApp.staticData[key] || nuxtApp.payload.data[key]
}
}
)
// Load related products after
watchEffect(async () => {
if (product.value?.category) {
const { data: related } = await $fetch<Product[]>(`/api/products/related/${product.value.category}`)
product.value.relatedProducts = related
}
})
</script>Patterns worth knowing
Unique keys
Keys are how Nuxt deduplicates and caches async state. Get them right.
// ✅ Good keying practices
const { data } = await useAsyncData(`user-${userId}`, () => $fetch(`/api/users/${userId}`));
// For data dependent on multiple parameters
const { data } = await useAsyncData(`search-${searchTerm}-${page}-${category}`, () =>
$fetch("/api/search", {
query: { q: searchTerm, page, category }
})
);A good key encodes everything that distinguishes one request from another. If two different searches share a key, they will clobber each other's data.
Reusable loading state composable
// components/DataLoader.vue
<script setup lang="ts">
interface AsyncDataState<T> {
data: Ref<T | null>
pending: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
}
// Reusable composable
function useAsyncDataWithState<T>(
key: string,
handler: () => Promise<T>
): AsyncDataState<T> {
const { data, pending, error, refresh } = useAsyncData(key, handler)
return {
data,
pending,
error,
refresh
}
}
// Usage in component
const { data: posts, pending, error } = useAsyncDataWithState(
'posts',
() => $fetch('/api/posts')
)
</script>
<template>
<div>
<div v-if="pending" class="loading">
Loading posts...
</div>
<div v-else-if="error" class="error">
Error: {{ error.message }}
<button @click="refresh">Retry</button>
</div>
<div v-else-if="data" class="posts">
<PostCard
v-for="post in data"
:key="post.id"
:post="post"
/>
</div>
</div>
</template>Lazy fetching and polling
For non-critical data that can load after the page renders:
// For non-critical data that can load later
const { data: comments } = await useLazyFetch(`/api/posts/${postId}/comments`, {
server: false, // Client-side only
lazy: true // Don't block rendering
});
// For frequently updated data
const { data: liveScore } = await useFetch("/api/live-score", {
server: false,
refresh: {
// Refresh every 30 seconds
interval: 30000
}
});Shallow refs and why they matter
Nuxt 4 switched the default data ref from deep to shallow reactivity. That means Vue no longer traverses the entire data structure to track changes. For large API responses, the CPU savings are real.
// In Nuxt 4, this is shallowRef by default
const { data } = await useAsyncData("large-dataset", () => $fetch("/api/large-dataset"));
// If you need deep reactivity (use with caution)
const { data } = await useAsyncData("deep-reactive", () => $fetch("/api/nested-data"), {
deep: true // Enables deep reactivity
});Most of the time you want the shallow default. If you mutate nested properties and need Vue to react, flip deep: true. But that is usually a sign you should restructure how you update data.
Caching patterns
// Component-level cache
const { data } = await useFetch("/api/config", {
key: "app-config",
// Cache for 5 minutes
server: true,
transform: data => {
// Data will remain in cache
return data;
}
});
// Conditional cache
const { data } = await useFetch("/api/user-preferences", {
key: `user-prefs-${userId}`,
server: false,
// Only cache if there are no errors
getCachedData: key => {
const cached = nuxtApp.staticData[key] || nuxtApp.payload.data[key];
return cached && !cached.error ? cached : null;
}
});TypeScript
Typing useFetch responses
// types/api.ts
export interface User {
id: number
name: string
email: string
avatar?: string
}
export interface ApiResponse<T> {
data: T
message: string
success: boolean
}
// pages/users/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: user } = await useFetch<ApiResponse<User>>(`/api/users/${route.params.id}`)
// Type-safe access
if (user.value?.data) {
console.log(user.value.data.name) // TypeScript knows the type
}
</script>Typing useAsyncData
// composables/useDashboard.ts
export interface DashboardData {
user: User;
stats: {
views: number;
clicks: number;
conversions: number;
};
recentActivity: Activity[];
}
export function useDashboardData() {
return useAsyncData<DashboardData>("dashboard", async () => {
const [userResponse, statsResponse, activityResponse] = await Promise.all([$fetch<ApiResponse<User>>("/api/user/profile"), $fetch<ApiResponse<Stats>>("/api/analytics/stats"), $fetch<ApiResponse<Activity[]>>("/api/activity/recent")]);
return {
user: userResponse.data,
stats: statsResponse.data,
recentActivity: activityResponse.data
};
});
}Debugging
// Enable debug mode in development
const { data } = await useFetch("/api/debug-example", {
// Show debug information in console
onRequestError({ request, error }) {
console.error("Request error:", { request, error });
},
onResponseError({ response }) {
console.error("Response error:", response.status, response.statusText);
},
onResponse({ response }) {
console.log("Response received:", response._data);
}
});Tracking slow requests
// Composable for measuring performance
function useTrackedFetch<T>(url: string, options = {}) {
const startTime = Date.now();
return useFetch<T>(url, {
...options,
onResponse() {
const duration = Date.now() - startTime;
console.log(`Fetch to ${url} took ${duration}ms`);
// Send to analytics if it's slow
if (duration > 1000) {
$fetch("/api/analytics/slow-request", {
method: "POST",
body: { url, duration }
});
}
}
});
}If you take one thing from this guide: $fetch for mutations and client-side calls, useFetch for simple SSR data loading, useAsyncData for everything else. Get the mental model right and the rest follows. The official Nuxt docs go deeper on each API if you want more.