Data Fetching
6. When do you use useFetch, useAsyncData and $fetch?
Use useFetch to load data for a component from a URL during render, useAsyncData to wrap any other async source (an SDK, several calls, a CMS client) the same way, and $fetch for calls made in response to user actions.
The first two put the server result into the Nuxt payload, so the client hydrates from it instead of calling the API again. useFetch(url, opts) is shorthand for useAsyncData with a generated key and $fetch, and it infers the response type from your server routes.
<script setup lang="ts">
// Rendering data: fetched on the server, reused on hydration
const { data: posts, status, error } = await useFetch('/api/posts')
// Several sources under one key
const { data: dashboard } = await useAsyncData('dashboard', async (_nuxtApp, { signal }) => {
const [user, stats] = await Promise.all([
$fetch('/api/me', { signal }),
$fetch('/api/stats', { signal }),
])
return { user, stats }
})
// Event handler: plain $fetch, no payload involved
async function like(id: number) {
await $fetch(`/api/posts/${id}/like`, { method: 'POST' })
}
</script>
The classic mistake is a bare await $fetch() in <script setup>.
It runs on the server during SSR and again in the browser during hydration, because nothing stores the result, so every page load hits the API twice and risks a hydration mismatch if the data changed between calls.
7. Find the bug: the profile page shows "logged out" on refresh.
<!-- app/pages/profile.vue -->
<script setup lang="ts">
const { data: me } = await useAsyncData('me', () => $fetch('/api/me'))
</script>
<template>
<p v-if="me">Signed in as {{ me.email }}</p>
<p v-else>Please sign in</p>
</template>
Bug: during SSR, $fetch('/api/me') is a server-to-server call that does not carry the browser's cookies, so /api/me sees an anonymous request and returns 401 or an empty user.
Client-side navigation works, because the browser sends cookies itself, which makes the bug look intermittent. Per the data fetching docs, Nuxt does not forward request headers from plain $fetch during SSR for security reasons.
Fix: use useFetch('/api/me'), which uses useRequestFetch under the hood, or call const requestFetch = useRequestFetch() and use it inside the handler. Nuxt strips hop-by-hop headers such as host, connection and accept when forwarding.
Inside a server route the equivalent is event.$fetch. A senior candidate also asks whether the page should be cached at all: per-user HTML must not sit behind an swr or isr rule.
8. How do keys work, and what happens when two components use the same key?
The key identifies the data in the payload and in the shared cache. If you omit it, Nuxt generates one from the file name and line number.
In Nuxt 4, all calls with the same key share the same data, error, status and pending refs, and Nuxt removes the data when the last component using it unmounts.
Because the refs are shared, some options must match across calls with the same key: the handler, deep, transform, pick, getCachedData, default and middleware. Nuxt warns in development if they conflict.
Options such as server, lazy, immediate, dedupe, watch, enabled and serialize may differ.
Keys can be reactive. A computed key such as () => `user-${route.params.id}` stores each user separately and refetches when the route changes.
A strong candidate mentions the Nuxt 4 rule for immediate: false: a key change only triggers a fetch if the data has already been fetched once, and useFetch now follows the same rule as useAsyncData.
9. Find the bug: a todo checkbox that never updates.
<script setup lang="ts">
const { data: todo } = await useFetch('/api/todos/1')
async function toggle() {
todo.value!.done = !todo.value!.done
await $fetch('/api/todos/1', { method: 'PATCH', body: { done: todo.value!.done } })
}
</script>
<template>
<label>
<input type="checkbox" :checked="todo?.done" @change="toggle" />
{{ todo?.title }}
</label>
</template>
Bug: in Nuxt 4, data is a shallowRef because deep defaults to false.
Assigning todo.value.done mutates a property inside the object without replacing .value, so Vue does not track the change and anything bound to todo.done keeps its old value.
Code ported from Nuxt 3 tutorials, where data was deep, breaks this way.
Fix: replace the object, todo.value = { ...todo.value!, done: !todo.value!.done }, or pass deep: true for this call.
A better answer also handles failure: apply the change optimistically, restore the old object if the PATCH throws, or call refresh() after the write so the server stays the source of truth.
10. What do lazy, await and status change about navigation?
On the server, Nuxt waits for every useFetch and useAsyncData call before rendering, with or without await, so the HTML always contains the data. The difference shows on client-side navigation.
With await and the default lazy: false, navigation blocks until the data is ready, which Nuxt implements with <Suspense>. With lazy: true (or useLazyFetch), the route renders at once and you show a loading state from status.
status is one of 'idle', 'pending', 'success' or 'error'. idle appears with immediate: false or with server: false during the server render.
data and error default to undefined in Nuxt 4, not null, so === null checks from Nuxt 3 code never match.
- With
server: false, the fetch does not start until hydration completes, so data is still undefined in <script setup> even if you await it.
A good answer frames this as a product decision: blocking navigation suits pages that are useless without data, and lazy loading suits pages with a usable shell. The docs recommend lazy: true with a loading state for a snappier feel.
11. How do getCachedData, dedupe and the abort signal work together?
getCachedData(key, nuxtApp, ctx) decides whether to reuse existing data instead of running the handler. The default returns payload data during hydration and prerendered static data otherwise.
In Nuxt 4 it is called on every fetch, including watcher triggers and refreshNuxtData, and ctx tells you the cause, so you can cache on navigation but always refetch on a manual refresh.
const { data } = await useFetch('/api/products', {
getCachedData(key, nuxtApp, ctx) {
if (ctx.cause === 'refresh:manual') return undefined // force a fetch
return nuxtApp.isHydrating
? nuxtApp.payload.data[key]
: nuxtApp.static.data[key]
},
})
dedupe controls overlapping executions: the default 'cancel' aborts the running request when a new one starts, and 'defer' keeps the running one and skips the new call.
Since 4.2 the handler receives a signal in its second argument, and Nuxt aborts it on a cancelling dedupe, on clear() and when the timeout option expires.
A candidate who passes that signal into $fetch has seen a search box return results out of order.
12. How do you give every API call the same base URL and auth header?
Since Nuxt 4.4, createUseFetch and createUseAsyncData build typed custom composables with your defaults, and Nuxt registers them for key injection so they behave like the built-ins during SSR.
Before that, teams wrapped useFetch by hand and often broke key generation.
// app/composables/useApiFetch.ts
export const useApiFetch = createUseFetch((options) => {
const config = useRuntimeConfig()
const token = useCookie('token') // read in setup context, not inside the hook
return {
...options,
baseURL: options.baseURL ?? config.public.apiBase,
onRequest({ options }) {
if (token.value) options.headers.set('Authorization', `Bearer ${token.value}`)
},
}
})
Nuxt 4.5 added a reactive enabled option that blocks every execution while it is false, useful for dependent queries such as "fetch orders once a customer is selected".
Nuxt 4.6 added addons to the factories through defineUseFetchAddon, which covers polling, refresh on focus and retries without forking the composable. A weak answer reaches for an Axios instance and loses payload hydration.