Zademy

Nuxt 4: $fetch, useFetch and useAsyncData - Complete Data Fetching Guide

Nuxt; Vue; Fetching; SSR; DataFetching
words words

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 useFetch and useAsyncData as 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. $fetch is 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.