React

Data fetching in Nuxt 3: useFetch, useAsyncData and $fetch

useFetch is useAsyncData plus $fetch. Use useFetch in setup for page data, useAsyncData for any other source, and $fetch inside event handlers.

Key points

  • useFetch calls $fetch on the server and moves the result to the client in the payload, with no second request.
  • The key must be stable: a random key breaks payload reuse and doubles the calls.
  • Inside a click handler useFetch does not work: use $fetch and refresh explicitly.

In Nuxt 3 useFetch is useAsyncData with $fetch already wired in: it runs the call on the server, puts the result in the payload and reuses it during hydration, with no second request from the browser.

The normal case: page data


const { data, status, error, refresh } = await useFetch("/api/posts", {
  key: "posts",
  query: { page: 1 },
  transform: (rows) => rows.filter((row) => row.published),
});



  Loading...
  Failed: {{ error.message }}
  
    {{ post.title }}
  

await before useFetch waits for the result before rendering, so the server sends HTML that is already full. Drop the await and the same data arrives in a stream, with a visible loading state.

Sources that are not URLs


const route = useRoute();

const { data: post } = await useAsyncData(
  () => `post-${route.params.slug}`,
  () => $fetch(`/api/posts/${route.params.slug}`),
  { watch: [() => route.params.slug] },
);

The key is a function of the route: it changes with the slug and the payload stays consistent. watch repeats the call when the slug changes, and $fetch inside the function is the call itself.

Inside an event you need $fetch

async function save(title: string) {
  await $fetch("/api/posts", {
    method: "POST",
    body: { title },
  });
  await refresh();
}

Fetching composables are called during setup, not inside a click handler. In the handler use $fetch and update through the refresh the composable returns, or write the state you manage yourself.

Which one to use

CaseWinnerWhy
Page data on the first renderuseFetch$fetch and payload already wired
Two calls to mergeuseAsyncDataOne key, two fetches inside Promise.all
A save from a button$fetchOutside setup the composable does not exist
Private user dataA server endpointThe payload travels to the browser, so it is not private

Common mistakes

  • A key built with Math.random(): the payload is not reused and the request fires twice.
  • Reading data.value after await useAsyncData without checking error: the template reads a null value.
  • Calling useFetch inside onMounted: the composable is outside the setup context.
  • Putting a token in the query with useFetch: it ends up in the payload and therefore in the HTML.

The limitation to plan for

Everything useFetch and useAsyncData retrieve ends up in the payload sent to the browser. It is not a private server cache: restricted data needs an endpoint that checks the session and returns only what the user may see.

Where to go next

In the catalogue: shadcn-vue, PrimeVue and Vue Flow; data-heavy sections are in /blocks. The guides: Nuxt 3 project structure and Composition API. The comparison: the four cache layers in Next.js.

Frequently asked questions

useFetch or useAsyncData?

useFetch is useAsyncData with $fetch already configured. Use useAsyncData when the source is not an HTTP call or when you merge several requests.

Why does the request fire twice?

The key changes between server and client. A stable, explicit key with no random values lets hydration reuse the payload.

Can I call useFetch inside an event?

No. useFetch runs in setup. Inside a handler use $fetch and then update through refresh or through state you manage.

Does sensitive data stay on the server?

No: with useFetch and useAsyncData the result lands in the payload sent to the browser. Private calls belong in a server endpoint.

How do I track the loading state?

useAsyncData returns status, pending and error. status is more precise: pending is true only between one load and the next.