Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 035 / 100

$fetch vs useFetch: Vue & Nuxt best practices explained

Pick the right tool.

Expona Admin7 min read

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.

typescript
<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.

typescript
<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:

typescript
// ❌ 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.

typescript
// ✅ 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:

typescript
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/products resolves 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 ofetch hooks in your config.

For example, if you want to add an authorization header to every request, configure it once in nuxt.config.ts:

typescript
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:

typescript
// ❌ 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:

typescript
// ❌ 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:

typescript
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:

typescript
// ❌ 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 $fetch at the top level of setup for rendering-critical data; use useFetch instead.
  • Both tools use ofetch under the hood, so configuration is shared and the API is familiar across your codebase.
  • Always await useFetch and wrap $fetch in try/catch to handle errors gracefully.
  • When refreshing data fetched with useFetch, call the refresh() method rather than making a new $fetch request.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles