
Nuxt 4 Data Fetching: useFetch vs useAsyncData
Nuxt 4 gives you several ways to request data, but they are not interchangeable. Using $fetch directly inside a server-rendered page can send the same request once on the server and again during browser hydration. Using useFetch everywhere can also hide cases where you need a stable cache key, several dependent requests, or a custom API client.
This guide explains when to use $fetch, useFetch, useAsyncData, and their lazy variants. It then builds production-ready patterns for external APIs, reactive filters, authentication, errors, refreshes, and duplicate-request prevention.
The short decision guide
- Use
$fetchfor event-driven requests such as submitting a form, deleting an item, or loading data only after a button click. - Use
useFetchfor a page or component that reads one HTTP endpoint and should work correctly with SSR. - Use
useAsyncDatawhen the async work is more complex than one endpoint: multiple requests, a database call in server code, a custom client, or computed aggregation. - Use lazy variants when navigation should finish before non-critical data is ready and the interface provides a loading state.
Why plain $fetch can run twice during SSR
Nuxt renders a universal page on the server and hydrates it in the browser. A direct request in component setup does not automatically place its result in the Nuxt payload:
<script setup lang="ts">
const products = await $fetch('/api/products')
</script>
The server fetches the products to render HTML, but the browser may request them again while hydrating because it does not receive an AsyncData payload for this call. The SSR-friendly replacement is:
<script setup lang="ts">
const { data: products, status, error } = await useFetch('/api/products')
</script>
useFetch wraps useAsyncData and $fetch. The server result is serialized into the Nuxt payload and reused during hydration.
Use useFetch for one HTTP resource
Assume a product listing accepts a category and page number:
<script setup lang="ts">
const route = useRoute()
const page = computed(() => Number(route.query.page ?? 1))
const category = computed(() => String(route.query.category ?? 'all'))
const { data, status, error, refresh } = await useFetch('/api/products', {
query: { page, category },
key: 'product-list',
default: () => ({ items: [], total: 0 })
})
</script>
Reactive values inside query are watched. When the route values change, Nuxt can refetch the resource. The explicit default keeps the template predictable before a response arrives.
Reduce payload size with transform
If an upstream API returns more data than the page needs, trim it before Nuxt serializes the result:
const { data: users } = await useFetch('/api/users', {
transform: response => response.items.map(user => ({
id: user.id,
name: user.name,
avatar: user.avatar
}))
})
This is not a substitute for a well-designed API, but it can prevent large unused objects from increasing the SSR payload.
Use useAsyncData for composed async work
useAsyncData is a better fit when a page combines several sources:
<script setup lang="ts">
const route = useRoute()
const productId = computed(() => String(route.params.id))
const { data, status, error } = await useAsyncData(
() => product-page:${productId.value},
async () => {
const [product, reviews] = await Promise.all([
$fetch(/api/products/${productId.value}),
$fetch(/api/products/${productId.value}/reviews)
])
return { product, reviews }
},
{ watch: [productId] }
)
</script>
The key identifies the cached result. A key that includes the product ID prevents one route's data from being reused for another product. Promise.all runs independent requests concurrently, reducing page latency compared with awaiting them sequentially.
Internal server route or external API?
When a Nuxt server route already represents the data you need, use a relative URL:
const { data } = await useFetch('/api/profile')
For an external backend, expose only the public base URL through runtime configuration:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: process.env.API_SECRET,
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE
}
}
})
<script setup lang="ts">
const config = useRuntimeConfig()
const { data } = await useFetch('/articles', {
baseURL: config.public.apiBase
})
</script>
Never place server-only secrets under runtimeConfig.public. Values there are available to browser code.
Create a custom API client for repeated rules
A plugin can centralize a base URL, timeout, and response behavior:
// app/plugins/api.ts
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const api = $fetch.create({
baseURL: config.public.apiBase,
timeout: 10_000,
onResponseError({ response }) {
if (response.status === 401) {
// Clear local auth state or redirect through app-specific logic.
}
}
})
return { provide: { api } }
})
Use that client through useAsyncData so SSR data is transferred instead of fetched twice:
const { $api } = useNuxtApp()
const { data: account } = await useAsyncData(
'account',
() => $api('/account')
)
Do not await a custom wrapper around useAsyncData inside the wrapper itself. Return the composable result and await it at the component call site.
Handle cookies and request headers safely
Browser cookies are not automatically identical to the headers available during server rendering. For same-origin requests, useFetch uses Nuxt's request-aware fetch behavior. For custom clients or cross-origin APIs, forward only the headers you actually need.
A server API route is often the safest boundary: the browser calls Nuxt, while Nuxt adds a private credential when calling the upstream service. This prevents long-lived service tokens from entering client JavaScript.
// server/api/account.get.ts
export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
return $fetch('/account', {
baseURL: config.upstreamBase,
headers: {
authorization: Bearer ${config.apiSecret}
}
})
})
Loading, error, and empty states
AsyncData exposes reactive status information. Treat an empty successful result differently from a failed request:
<template>
<ProductSkeleton v-if="status === 'pending'" />
<ErrorPanel
v-else-if="error"
:message="error.statusMessage ?? 'Unable to load products'"
@retry="refresh"
/>
<EmptyState v-else-if="!data?.items.length" />
<ProductGrid v-else :items="data.items" />
</template>
Do not expose raw upstream stack traces. Server routes should translate provider failures into controlled HTTP errors with messages appropriate for users.
Refresh data after a mutation
Mutations are event-driven, so direct $fetch is appropriate:
const saving = ref(false)
async function createProduct(input: CreateProductInput) {
saving.value = true
try {
await $fetch('/api/products', {
method: 'POST',
body: input
})
await refresh()
} finally {
saving.value = false
}
}
Refresh the smallest relevant AsyncData entry. Globally refreshing every key after each mutation adds unnecessary load and can make the interface jump.
When to use lazy fetching
Default AsyncData can block navigation until data resolves. For recommendations or other secondary content, allow navigation to finish and show a placeholder:
const { data, status } = await useLazyFetch('/api/recommendations', {
default: () => []
})
Do not make primary SEO content lazy only to hide a slow endpoint. Fix the endpoint, cache it appropriately, or render a stable page shell with clear loading behavior.
Common data-fetching mistakes
- Using $fetch directly in component setup: it can duplicate SSR and hydration requests.
- Reusing one key for different parameters: include resource identity in the AsyncData key.
- Putting secrets in public runtime config: keep private values server-only.
- Watching a newly created object: use stable refs or computed values so reactive changes are intentional.
- Calling the external API from every component: centralize shared rules in a server route or custom client.
- Fetching independent resources sequentially: use concurrent requests when there is no dependency.
- Ignoring empty and error states: both need explicit interface behavior.
Production checklist
- Page data uses
useFetchoruseAsyncDatafor SSR payload transfer. - Event-driven mutations use
$fetchwith disabled or pending button states. - AsyncData keys include the identity of parameterized resources.
- External API base URLs come from runtime configuration.
- Service credentials remain inside Nuxt server code.
- Response payloads contain only fields required by the page.
- Primary, secondary, empty, pending, and error states are tested.
- Refreshes target only data affected by a mutation.
- SSR and browser network logs show no unexplained duplicate request.
Choose the smallest Nuxt data-fetching tool that preserves SSR correctness: useFetch for one HTTP resource, useAsyncData for custom asynchronous work, and $fetch for user-triggered operations.