---
title: "useFetch or $fetch? Picking the right one in Nuxt · vuejs.school"
description: "One question decides it. If the page needs the data to render, call useFetch in setup. If a click causes the request, call $fetch in the handler."
canonical_url: "https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt"
meta:
  "og:description": "One question decides it. If the page needs the data to render, call useFetch in setup. If a click causes the request, call $fetch in the handler."
  "og:image": "https://vuejs.school/_og/d/a_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dA%2Cdescription_~T25lIHF1ZXN0aW9uIGRlY2lkZXMgaXQuIElmIHRoZSBwYWdlIG5lZWRzIHRoZSBkYXRhIHRvIHJlbmRlciwgY2FsbCB1c2VGZXRjaCBpbiBzZXR1cC4gSWYgYSBjbGljayBjYXVzZXMgdGhlIHJlcXVlc3QsIGNhbGwgJGZldGNoIGluIHRoZSBoYW5kbGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VmZXRjaC1vci1mZXRjaC1waWNraW5nLXRoZS1yaWdodC1vbmUtaW4tbnV4dCI%2Cs_JDjh5t2_HPDGzlKT.png"
  "og:image:alt": "useFetch or $fetch? Picking the right one in Nuxt · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "useFetch or $fetch? Picking the right one in Nuxt · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dA%2Cdescription_~T25lIHF1ZXN0aW9uIGRlY2lkZXMgaXQuIElmIHRoZSBwYWdlIG5lZWRzIHRoZSBkYXRhIHRvIHJlbmRlciwgY2FsbCB1c2VGZXRjaCBpbiBzZXR1cC4gSWYgYSBjbGljayBjYXVzZXMgdGhlIHJlcXVlc3QsIGNhbGwgJGZldGNoIGluIHRoZSBoYW5kbGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VmZXRjaC1vci1mZXRjaC1waWNraW5nLXRoZS1yaWdodC1vbmUtaW4tbnV4dCI%2Cs_JDjh5t2_HPDGzlKT.png"
  "twitter:image:alt": "useFetch or $fetch? Picking the right one in Nuxt · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Ask whether the page needs the data to render

A product page needs the product. A notes page needs the notes. That data should be in the HTML the server sends, and the browser should not have to ask for it again. Everything else happens because someone did something: they submitted a form, deleted a note, pressed refresh.

The two tools split along that line:

- `$fetch` is the plain request function. Nuxt ships the ofetch library and makes it available everywhere under that name. It parses JSON for you and throws on an error status.
- `useFetch` is a composable built on `$fetch`. It runs the request once during server rendering and stores the result in the **payload**, a JavaScript object embedded in the page. In the browser it reads the payload instead of fetching again, and it gives you reactive `data`, `status` and `error` refs.

`**useFetch()**`

Data a page needs to render. Called in setup, runs on the server, and the result travels to the browser in the payload.

`**$fetch**`

Things a user does: submit, delete, refresh. Called from an event handler, and in server routes.

## Load page data with `useFetch`, not `$fetch`

The first classic mistake is calling `$fetch` directly in setup:

*app/pages/notes.vue**Breaks***

```
<script setup lang="ts">
const notes = await $fetch('/api/notes')
</script>

<template>
  <ul>
    <li v-for="note in notes" :key="note.id">{{ note.text }}</li>
  </ul>
</template>
```

The page renders, so it looks fine. Open the browser's Network tab and reload, though, and you will see a request to `/api/notes` from the browser. The server called it while rendering the HTML. Then the browser ran the same setup code during **hydration**, the step where Vue takes over the server-rendered HTML, and called it again. `$fetch` does not hand anything from server to client. If the data changed between the two calls, the HTML and the client state disagree and you get a hydration mismatch.

*app/pages/notes.vue**Works***

```
<script setup lang="ts">
const { data: notes } = await useFetch('/api/notes', {
  default: () => [],
})
</script>

<template>
  <ul>
    <li v-for="note in notes" :key="note.id">{{ note.text }}</li>
  </ul>
</template>
```

Now the request happens once, on the server. The browser finds the notes in the payload, and the Network tab stays quiet on first load.

`default` gives `data` a value before the request finishes, so the template never sees `undefined`. The types come for free: `useFetch` infers the response type from your server route, so TypeScript checks `note.text`.

## Refetch when inputs change, or wait with `enabled`

`useFetch` reruns when its inputs change. Pass the URL as a function, or pass reactive values in `query`, and Nuxt refetches whenever they change. A string built once with `` `/api/notes/${id.value}` `` does not update, because it was computed when setup ran; `` () => `/api/notes/${id.value}` `` does.

The `enabled` option holds a request back until a condition is true. While it is `false`, nothing runs: not the first fetch, not `refresh`, not the watchers. A search box is the typical case, here backed by a `server/api/search.get.ts` route that returns the matching notes:

*app/pages/search.vue*

```
<script setup lang="ts">
const query = ref('')

const { data: results, status } = await useFetch('/api/search', {
  query: { q: query },
  enabled: () => query.value.trim().length > 2,
  default: () => [],
})
</script>

<template>
  <div>
    <label for="search">Search notes</label>
    <input id="search" v-model="query" type="search">
    <p v-if="status === 'pending'">Searching...</p>
    <ul>
      <li v-for="note in results" :key="note.id">{{ note.text }}</li>
    </ul>
  </div>
</template>
```

Typing the third character changes `query`, which triggers a refetch, and `enabled` now allows it. Switching `enabled` back on does not fetch by itself; something has to trigger the request, and here the change to `query` does. If `enabled` turns `false` while a request is in flight, that request is cancelled and the current results stay on screen.

## Send user actions with `$fetch`, then refresh

The second classic mistake is the mirror image: `useFetch` inside an event handler.

*app/pages/notes.vue**Breaks***

```
<script setup lang="ts">
const { data: notes } = await useFetch('/api/notes', { default: () => [] })
const text = ref('')

async function addNote() {
  await useFetch('/api/notes', { method: 'POST', body: { text: text.value } })
  text.value = ''
}
</script>
```

A composable belongs in setup. Called from a handler, after the component has mounted, it cannot take part in server rendering. In development Nuxt 4.5 warns with the code `NUXT_E3003`, and the fix it suggests is `$fetch` for requests triggered by events. The list on the page does not change either, because nothing told the first `useFetch` to fetch again.

*app/pages/notes.vue**Works***

```
<script setup lang="ts">
const { data: notes, refresh } = await useFetch('/api/notes', { default: () => [] })
const text = ref('')

async function addNote() {
  await $fetch('/api/notes', { method: 'POST', body: { text: text.value } })
  text.value = ''
  await refresh()
}
</script>

<template>
  <div>
    <form @submit.prevent="addNote">
      <label for="note-text">New note</label>
      <input id="note-text" v-model="text" required>
      <button type="submit">Add</button>
    </form>
    <ul>
      <li v-for="note in notes" :key="note.id">{{ note.text }}</li>
    </ul>
  </div>
</template>
```

`$fetch` sends the note. `refresh()` reruns the page's `useFetch`, so the list shows the new note.

Server routes use `$fetch` too, never `useFetch`, whether they call another route or an external API. When the URL is one of your own API routes, Nuxt calls the handler directly on the server instead of making a real HTTP request.

## Use `useAsyncData` when the source is not a URL

`useFetch(url)` is shorthand for `useAsyncData` plus `$fetch`. When the data does not come from a URL you can hand to `$fetch`, use `useAsyncData` directly and give it a function. A content query, an SDK client or several requests combined with `Promise.all` all fit. With the Nuxt Content module and a `blog` collection, a list page looks like this:

*app/pages/blog/index.vue*

```
<script setup lang="ts">
const { data: posts } = await useAsyncData('blog-list', () =>
  queryCollection('blog').order('date', 'DESC').all(),
)
</script>

<template>
  <ul>
    <li v-for="post in posts" :key="post.path">
      <NuxtLink :to="post.path">{{ post.title }}</NuxtLink>
    </li>
  </ul>
</template>
```

You get the same payload transfer, the same `status`, `error`, `refresh` and `clear`.

The first argument, `'blog-list'`, is the **key**: the name Nuxt stores the result under in the payload. You can leave it out, but the key Nuxt then generates only knows where the call sits in your code, so the docs recommend passing your own string.

Calls with the same key share one `data`, `status` and `error`. `useFetch` builds its key from the URL, the options and where the call sits, so two components calling the same URL get different keys and each makes its own request. To share one result, give both the same `key` option.

Make the function return a value. If it returns `undefined`, Nuxt 4.5 warns in development with `NUXT_E3006` that the request may be duplicated on the client, so return `null` when there is nothing.

## Load data the first paint does not need

An awaited `useFetch` blocks navigation until the data arrives, so a page never appears half empty. Two options change that:

- `lazy: true` lets navigation happen at once, and you show a loading state yourself.
- `server: false` skips the server and fetches only in the browser. On the first load, that happens after hydration.

Together they suit data that is not needed for the first paint and does not matter to search engines, like a stats panel. This one reads from a `server/api/notes/stats.get.ts` route that returns `{ total, thisWeek }`:

*app/components/NoteStats.vue*

```
<script setup lang="ts">
const { data: stats, status, error, refresh } = useFetch('/api/notes/stats', {
  lazy: true,
  server: false,
})
</script>

<template>
  <aside>
    <p v-if="status === 'pending'">Counting notes...</p>
    <p v-else-if="error">
      Could not load stats (error {{ error.status }}).
      <button type="button" @click="refresh()">Try again</button>
    </p>
    <p v-else-if="stats">{{ stats.total }} notes, {{ stats.thisWeek }} this week</p>
  </aside>
</template>
```

There is no `await` before `useFetch` here. Awaiting a lazy call does not wait for the data on client navigation, so it would only mislead the next reader.

## Read errors, then refresh or clear

Two things in the stats panel are called "status", and they mean different things:

- The `status` ref says where the request is: `'idle'`, `'pending'`, `'success'` or `'error'`.
- `error.status` is the HTTP status code from the server, such as `404` or `500`. A request that never reaches the server, because the network is down for example, also reports `500`, the default status of Nuxt's error object.

For the message the server sent, read the response body. A route that throws `createError({ status: 400, statusText: 'Note text is required' })` sends that text as `statusMessage` in its JSON body, and Nuxt puts the body in `error.data` (typed `unknown`, so check its shape before you read it). `error.statusText` holds the same text only over HTTP/1.1, where it is copied from the response's status line (`HTTP/1.1 400 Note text is required`); HTTP/2 sends no text there.

`refresh()` reruns the request and keeps the current data on screen until the new data arrives. `clear()` does the opposite: it resets `data` to `undefined` or your `default`, clears `error`, sets `status` back to `'idle'` and cancels a pending request. Call it when the data should no longer be visible, for example right after a user signs out.

**Check it stuck**3 questions from the Data fetching quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/data-fetching?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor**](https://vuejs.school/community/blog/whats-new-in-nuxt-4-6) [Deep dive · 7 min read**What Nitro and h3 do under your Nuxt server routes**](https://vuejs.school/community/blog/what-nitro-and-h3-do-under-your-server-routes)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-09-22","description":"One question decides it. If the page needs the data to render, call useFetch in setup. If a click causes the request, call $fetch in the handler.","name":"useFetch or $fetch? Picking the right one in Nuxt","url":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt","breadcrumb":{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-09-22","description":"One question decides it. If the page needs the data to render, call useFetch in setup. If a click causes the request, call $fetch in the handler.","headline":"useFetch or $fetch? Picking the right one in Nuxt","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dA%2Cdescription_~T25lIHF1ZXN0aW9uIGRlY2lkZXMgaXQuIElmIHRoZSBwYWdlIG5lZWRzIHRoZSBkYXRhIHRvIHJlbmRlciwgY2FsbCB1c2VGZXRjaCBpbiBzZXR1cC4gSWYgYSBjbGljayBjYXVzZXMgdGhlIHJlcXVlc3QsIGNhbGwgJGZldGNoIGluIHRoZSBoYW5kbGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VmZXRjaC1vci1mZXRjaC1waWNraW5nLXRoZS1yaWdodC1vbmUtaW4tbnV4dCI%2Cs_JDjh5t2_HPDGzlKT.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"useFetch or $fetch? Picking the right one in Nuxt","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dA%2Cdescription_~T25lIHF1ZXN0aW9uIGRlY2lkZXMgaXQuIElmIHRoZSBwYWdlIG5lZWRzIHRoZSBkYXRhIHRvIHJlbmRlciwgY2FsbCB1c2VGZXRjaCBpbiBzZXR1cC4gSWYgYSBjbGljayBjYXVzZXMgdGhlIHJlcXVlc3QsIGNhbGwgJGZldGNoIGluIHRoZSBoYW5kbGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VmZXRjaC1vci1mZXRjaC1waWNraW5nLXRoZS1yaWdodC1vbmUtaW4tbnV4dCI%2Cs_JDjh5t2_HPDGzlKT.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlRmV0Y2ggb3IgJGZldGNoPyBQaWNraW5nIHRoZSByaWdodCBvbmUgaW4gTnV4dA%2Cdescription_~T25lIHF1ZXN0aW9uIGRlY2lkZXMgaXQuIElmIHRoZSBwYWdlIG5lZWRzIHRoZSBkYXRhIHRvIHJlbmRlciwgY2FsbCB1c2VGZXRjaCBpbiBzZXR1cC4gSWYgYSBjbGljayBjYXVzZXMgdGhlIHJlcXVlc3QsIGNhbGwgJGZldGNoIGluIHRoZSBoYW5kbGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VmZXRjaC1vci1mZXRjaC1waWNraW5nLXRoZS1yaWdodC1vbmUtaW4tbnV4dCI%2Cs_JDjh5t2_HPDGzlKT.png"}]}
```