Nuxt has three ways to get data, and they look alike at first: useFetch, $fetch and useAsyncData. Pick the wrong one and your page fetches the same data twice, or a button seems to do nothing. One question about the data tells you which one to reach for.
The examples use Nuxt 4.5 with the app/ directory: pages in app/pages/, components in app/components/, and server routes in server/ at the project root. The app is a small notes list, served by a server/api/notes.get.ts route that returns an array of { id, text } objects and a server/api/notes.post.ts route that adds one.
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:
$fetchis 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.useFetchis 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 reactivedata,statusanderrorrefs.
useFetch()Data a page needs to render. Called in setup, runs on the server, and the result travels to the browser in the payload.
$fetchThings 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:
<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.
<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:
<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.
<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.
<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:
<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: truelets navigation happen at once, and you show a loading state yourself.server: falseskips 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 }:
<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
statusref says where the request is:'idle','pending','success'or'error'. error.statusis the HTTP status code from the server, such as404or500. A request that never reaches the server, because the network is down for example, also reports500, 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.