Nuxt 4: $fetch, useFetch and useAsyncData - Complete Data Fetching Guide
Data fetching management in Nuxt is organized in a three-level hierarchy, where each tool has a distinct architectural role:
| Tool | Architectural Role | Main Purpose | SSR State Transfer (Hydration) |
|---|---|---|---|
| $fetch | Low-level Utility (Raw HTTP Client) | Execute pure HTTP network requests. | No. If used directly in SSR components, it causes double fetching. |
| useAsyncData | State Primitive (Core Primitive) | Manages the lifecycle of asynchronous state and guarantees data transfer between server and client. | Yes. Captures the result, serializes it in Nuxt's payload, and avoids double requests. |
| useFetch | Convenience Wrapper (Developer Wrapper) | Simplifies the most common use case: fetching data from a single HTTP endpoint. | Yes. Works internally with useAsyncData, inheriting its capabilities. |
useFetch is essentially syntactic sugar (a shortcut) to implement useAsyncData together with $fetch.
When to Use Each One
The choice fundamentally depends on the architectural context of the operation:
1. useFetch: Simple Initial Data Retrieval (GET)
It's recommended to use useFetch for initial data retrieval through GET requests from a single endpoint, especially when you need Server-Side Rendering (SSR) support and want to avoid double data fetching. It's the option with the cleanest syntax.
Clear Example: Getting a list of articles for a page.
// 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>2. useAsyncData: Complex Asynchronous Logic or Non-HTTP Data Sources
It's mandatory to use useAsyncData when the asynchronous operation is not a simple HTTP call to an endpoint. It's the main tool for wrapping any asynchronous promise.
Clear Examples:
-
Parallel calls: Orchestrate multiple simultaneous
$fetchcalls and combine them.const { data } = await useAsyncData("dashboard", async () => { const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]); return { user, stats }; }); -
Using third-party clients: Integrating clients that don't use
$fetch, such as GraphQL clients or a database ORM.// myGetFunction is a custom function from your data client const { data } = await useAsyncData("users", () => myGetFunction("users"));
3. $fetch: Mutations and Client/Server-Driven Actions
It's explicitly recommended to use $fetch for interactions that are fundamentally client-side and are driven by user events (like clicks or form submissions). It's also the best option for low-level utility calls that don't require SSR state synchronization.
Clear Examples:
-
Mutations (POST/PUT/DELETE): Sending form data. Using
useFetchfor mutations is generally inefficient since it doesn't benefit from state hydration.// Used inside an event handler async function submitForm() { await $fetch("/api/contact", { method: "POST", body: formData.value }); } -
Internal Server Route Calls: Within Nuxt's API routes (e.g.,
server/api),$fetchavoids the HTTP request and calls the handler function directly, optimizing server-side speed.
Advantages and Disadvantages
| Tool | Advantages | Disadvantages |
|---|---|---|
| $fetch | Internal call optimization: Calls internal server routes directly, without HTTP round-trip (latency savings). Ideal for mutations: Direct and simple use for client-side actions. | Risk of Double Fetching: If used in components without useAsyncData, the client executes the request again during hydration. |
| useAsyncData | Total control: Allows wrapping any asynchronous logic (not just HTTP). SSR-Friendly: Guarantees that data is fetched only once (on the server) and transferred to the client (preventing double-fetching). Complete reactive state: Returns data, pending, error, and status (a string indicating the state: "idle", "pending", "success", "error"). Nuxt 4 Optimization: The data value is shallowRef by default (shallow reactivity), which reduces CPU overhead with large data structures, improving performance. | More verbose: Requires explicitly defining the handler and a unique key. |
| useFetch | Simple syntax (DX): The simplest way to make an SSR-safe request. Automatic keying mechanism: Automatically generates a key based on URL and options. Strong typing: Can infer response types and URL types from server routes. | Limited: Only suitable for calls to a single HTTP endpoint. Not recommended for mutations (POST, PUT, DELETE) in the setup phase. |
When to Use from Client-side vs Server-side
The key differentiation here is the need for State Transfer for universal rendering (SSR), which prevents "double data fetching".
Server-side (SSR) and Universal Rendering
When using Nuxt for server-side rendering and you need data to be present in the initial HTML (essential for SEO and performance), you must use the functions that handle state transfer through the payload:
| Context | Recommended Tool | Reason |
|---|---|---|
| Initial data retrieval (GET) in pages/components. | useFetch or useAsyncData | Ensure data is fetched only once on the server and hydrated on the client, avoiding double requests. |
| Internal API Route calls (from server code). | $fetch | Nuxt intercepts the call and executes it directly, without making a real HTTP request, which is faster. |
Warning: If you use $fetch only for initial data fetching in a server-rendered component (<script setup>), the client will execute the network request again during hydration, resulting in double fetching.
Client-side
Operations that execute after hydration or in response to user interaction should use the low-level utility:
| Context | Recommended Tool | Reason |
|---|---|---|
| Mutations (POST, PUT, DELETE) triggered by user events. | $fetch | It's the preferred way to make direct client-side HTTP calls. The state doesn't need to be transferred from the server, so the useAsyncData overhead is unnecessary. |
| Non-SEO critical data or client-dependent data (e.g., geolocation). | useFetch/useAsyncData with server: false | This disables handler execution on the server, postponing data fetching until client-side hydration is complete. |
| Refresh existing data or execute a deferred function. | refresh()/execute() (returned by useFetch/useAsyncData) | Allow re-executing the data fetching logic manually without needing to use $fetch. |
Think of
useFetchanduseAsyncDataas a safe that travels from server to client: the server fills it with data and seals it. When it arrives at the client, the client doesn't have to go out to fetch the data (avoiding double requests), it just has to open the safe.$fetch, on the other hand, is a simple phone call to request data; if the server makes the call, the client has to make it again to get its own copy.
Advanced Practical Examples
Optimized Loading Pattern with useAsyncData
// 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>Mutations with $fetch and 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>useFetch with Data Transformation
// 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>Best Practices and Patterns
1. Unique Key Strategy
// ✅ 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 }
})
);2. Loading State Management
// 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>3. Network Optimization with useLazyFetch
// 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
}
});Performance Considerations
1. Shallow Refs in Nuxt 4
Nuxt 4 optimizes performance by using shallowRef by default for data:
// 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
});2. Cache Strategies
// 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 Integration
Strong Typing for useFetch
// 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 for 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 and Monitoring
Debugging Tools
// 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);
}
});Performance Monitoring
// 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 }
});
}
}
});
}Conclusion
The correct choice between $fetch, useFetch, and useAsyncData is fundamental for building efficient and maintainable Nuxt 4 applications:
$fetchfor mutations and direct client-side callsuseFetchfor simple data fetching with SSRuseAsyncDatafor complex asynchronous logic and total state control
Understanding these differences not only prevents common problems like double fetching but also allows you to make the most of Nuxt 4's universal rendering capabilities, building faster, more efficient applications with better user experience.
This guide is based on the official Nuxt 4 documentation and best practices established by the community. To dive deeper, consult the official Nuxt documentation and the examples in the Nuxt repository.