When building Nuxt applications, you'll reach for data-fetching utilities constantly—and choosing between $fetch vs useFetch can make the difference between smooth renders and race conditions. Both tools are fundamental to Nuxt's data strategy, yet they serve distinct purposes in your component lifecycle. Understanding when and how to use each one prevents common pitfalls that leak into production.
useFetch for data needed during render
The useFetch composable is your primary tool for fetching data that your component needs before it displays. When you call useFetch at the top level of your component's setup, Nuxt automatically manages the request lifecycle, waits for the data, and ensures your template has what it needs during server-side rendering (SSR).
Here's where this matters: imagine a product detail page in an e-commerce platform. The page cannot render meaningfully without the product ID, name, price, and description. Using useFetch ensures Nuxt fetches this data on the server before sending HTML to the browser.
<script setup lang="ts">
const route = useRoute()
const productId = route.params.id
// useFetch automatically handles SSR, caching, and lifecycle
const { data: product, pending, error } = await useFetch(
`/api/products/${productId}`
)
</script>
<template>
<div v-if="pending">Loading product...</div>
<div v-else-if="error">Failed to load product</div>
<div v-else>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<span>${{ product.price }}</span>
</div>
</template>Notice the await keyword. This is critical: useFetch is suspensible, meaning Nuxt halts rendering until the request completes. Your server renders the full HTML before responding to the client, eliminating flash-of-loading-state problems.
Nuxt also deduplicates requests automatically. If two components on the same page call useFetch with the same URL, Nuxt fires the request once and shares the response. This reduces unnecessary network traffic and improves perceived performance.
$fetch for event handlers like form submits
The $fetch utility is lighter and event-driven. Use it inside event handlers, watchers, or anywhere you're responding to user interaction rather than rendering. When a user submits a form, clicks a button, or triggers an action, $fetch handles the request without involving Nuxt's suspense or SSR machinery.
Consider a CRM where users save contact details. The form is already rendered; the user fills fields and clicks submit. You don't need SSR for that—you need a quick, simple API call.
<script setup lang="ts">
const contactForm = ref({
name: '',
email: '',
phone: ''
})
const isSubmitting = ref(false)
const submitMessage = ref('')
const handleSubmit = async () => {
isSubmitting.value = true
submitMessage.value = ''
try {
const result = await $fetch('/api/contacts', {
method: 'POST',
body: contactForm.value
})
submitMessage.value = 'Contact saved successfully'
contactForm.value = { name: '', email: '', phone: '' }
} catch (err) {
submitMessage.value = 'Failed to save contact'
console.error(err)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="contactForm.name" placeholder="Name" />
<input v-model="contactForm.email" placeholder="Email" />
<input v-model="contactForm.phone" placeholder="Phone" />
<button :disabled="isSubmitting">
{{ isSubmitting ? 'Saving...' : 'Save' }}
</button>
</form>
<p v-if="submitMessage">{{ submitMessage }}</p>
</template>The key difference: you're explicitly managing loading and error states with refs. The component remains interactive while the request is in flight. There's no blocking, no SSR involvement—just a user action triggering a network call.
$fetch also works in Nuxt middleware, plugins, and server routes without any special considerations. It uses the same underlying HTTP client (ofetch) so the API is consistent across your codebase.
Avoid $fetch at top level in setup
This is where many teams stumble. Using $fetch directly in your component's setup function (without awaiting with useFetch) creates a race condition on the server.
Here's what goes wrong:
// ❌ WRONG: $fetch at top level
<script setup lang="ts">
const products = ref([])
// This runs on the server, but doesn't block rendering
$fetch('/api/products').then(data => {
products.value = data
})
</script>
<template>
<div>{{ products.length }} products</div> <!-- Empty on initial HTML -->
</template>On the server, this sends HTML with an empty products array to the browser because the request is still in flight when rendering finishes. The client then hydrates, the request completes, and Vue updates the DOM. Users see a flicker, and your SEO suffers because search engines see the empty state in the HTML.
The fix is straightforward: use useFetch when you need the data for rendering.
// ✅ CORRECT: useFetch blocks rendering
<script setup lang="ts">
const { data: products } = await useFetch('/api/products')
</script>
<template>
<div>{{ products.length }} products</div> <!-- Correct count in HTML -->
</template>If you absolutely must call $fetch in setup for some reason (for example, to compute a value that other reactive properties depend on), wrap it with useFetch:
const { data: config } = await useFetch('/api/config')
const apiKey = config.value?.apiKey
const otherThing = computed(() => apiKey ? doSomething(apiKey) : null)
This ensures Nuxt waits for the data before rendering.
Both use ofetch under the hood
Understanding the foundation helps you debug and optimize. Both useFetch and $fetch are built on ofetch, a lightweight HTTP client that Nuxt configures automatically.
This means:
- Consistent API: Both use the same request/response handling, so switching between them is mental, not syntactic.
- Auto baseURL: Nuxt sets a baseURL from your
nuxt.config.ts, so/api/productsresolves correctly on server and client. - Cookie forwarding: Both automatically include cookies in requests, useful for sessions and CSRF tokens.
- Error handling: Both throw on non-2xx responses by default, unless you configure otherwise.
- Interceptors: You can hook into request/response lifecycle via
ofetchhooks in your config.
For example, if you want to add an authorization header to every request, configure it once in nuxt.config.ts:
export default defineNuxtConfig({
$fetch: {
baseURL: 'https://api.example.com',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
}
})
Both useFetch and $fetch respect this configuration, so you configure once and both tools inherit the behavior.
Common mistakes
Mistake 1: Forgetting the await with useFetch
Omitting await before useFetch causes rendering to proceed immediately with undefined data:
// ❌ WRONG
const { data: user } = useFetch('/api/user')
// ✅ CORRECT
const { data: user } = await useFetch('/api/user')Mistake 2: Calling useFetch conditionally
Composables must be called at the top level of setup, not inside if blocks or loops. If you need conditional fetching based on route params or reactive values, use the options parameter instead:
// ❌ WRONG
if (userId.value) {
const { data } = await useFetch(`/api/users/${userId.value}`)
}
// ✅ CORRECT
const { data } = await useFetch(() =>
userId.value ? `/api/users/${userId.value}` : null
)
Mistake 3: Mixing $fetch and useFetch for the same data
If you fetch data with useFetch and then later call $fetch to refresh it, you're bypassing Nuxt's deduplication and caching. Pick one tool and stick with it, or use useFetch's refresh method:
const { data: product, refresh } = await useFetch(
`/api/products/${productId}`
)
// To refresh:
await refresh()
// Not:
// const newData = await $fetch(`/api/products/${productId}`)
Mistake 4: Not handling errors from $fetch
$fetch throws on errors, so always wrap it in try/catch or provide error handling in your UI:
// ❌ WRONG: Error crashes the component
await $fetch('/api/data')
// ✅ CORRECT
try {
await $fetch('/api/data')
} catch (err) {
console.error('Request failed:', err)
}
Key takeaways
- useFetch is for data your component needs to render. It blocks SSR, prevents hydration mismatches, and automatically deduplicates requests.
- $fetch is for event-driven requests like form submissions and user interactions. It's lightweight and doesn't involve Nuxt's suspense lifecycle.
- Never use
$fetchat the top level of setup for rendering-critical data; useuseFetchinstead. - Both tools use
ofetchunder the hood, so configuration is shared and the API is familiar across your codebase. - Always await
useFetchand wrap$fetchin try/catch to handle errors gracefully. - When refreshing data fetched with
useFetch, call therefresh()method rather than making a new$fetchrequest.






Comments
Loading comments…