If a Nuxt interview has one guaranteed question, it is "what is the difference between useFetch, useAsyncData and $fetch". The good answer explains the payload: data fetched on the server is serialised into the HTML and reused during hydration, and only the composables participate in that. Everything else on this page, keys, lazy, getCachedData, headers, is a consequence.
$fetch is ofetch: a fetch wrapper that parses JSON, throws on error status, retries, and supports interceptors and a baseURL. On the server, calls to your own /api/* routes are dispatched in-process to Nitro (no HTTP round trip). Use it in event handlers, in server code, and inside useAsyncData handlers. Calling it directly in setup runs it on the server and again on the client with nothing in the payload.useAsyncData(key, handler, options) runs any async function during SSR, stores the result under key in the payload, and on the client resolves from the payload instead of re-running. useFetch(url, options) is useAsyncData with a $fetch handler and an auto-generated key derived from the URL and options. Reactive URLs (useFetch(() => /api/users/${id.value})) refetch when the source changes.useAsyncData keys from the file location when omitted, but explicit keys are the safe habit for anything called in more than one place.data (undefined until resolved in Nuxt 4, or default()), status ('idle' | 'pending' | 'success' | 'error'), error, refresh()/execute(), clear(). data is shallow in Nuxt 4 (deep: false): replace it, do not mutate nested properties and expect updates.lazy (do not block navigation; render with status), server: false (skip on the server, fetch after hydration), immediate: false (wait for execute()), default, transform, pick, watch (extra reactive sources), dedupe ('cancel' the in-flight request, or 'defer' to reuse it), getCachedData (decide what counts as cached). useLazyFetch/useLazyAsyncData are shorthands for lazy: true.getCachedData(key, nuxtApp, ctx) defaults to "the payload entry or static data", which is what makes hydration free. Return your own value to implement TTLs or to reuse across navigations; ctx.cause tells you whether this is the initial call, a refresh, or a watch trigger.$fetch to your API does not carry the browser's cookies. useRequestFetch() returns a $fetch that forwards the incoming request's headers; useRequestHeaders(['cookie']) gives you specific ones to pass manually.onRequest, onResponse, onRequestError, onResponseError) and $fetch.create({ baseURL, headers, onResponseError }) in a plugin give you a project-wide $api with auth and error mapping.useFetch; they land in error. Turn a missing resource into a real 404 with throw createError({ status: 404, fatal: true }) after checking error.value.refresh() on the returned object, refreshNuxtData(key) from anywhere, clearNuxtData(key) to drop cached entries. After a mutation, refresh the affected keys rather than mutating data by hand.The three tools in their right places:
<script setup lang="ts">
const route = useRoute()
// 1. Page data: on the server, into the payload, reused on hydration; refetches when the id changes.
const { data: user, status, error, refresh } = await useFetch(() => `/api/users/${route.params.id}`, {
key: () => `user:${route.params.id}`,
pick: ['id', 'name', 'email'],
})
if (error.value?.status === 404) {
throw createError({ status: 404, statusText: 'User not found', fatal: true })
}
// 2. Non-critical data: do not block navigation, render a skeleton from status.
const { data: activity, status: activityStatus } = useLazyFetch(() => `/api/users/${route.params.id}/activity`, {
server: false, // personal, uncacheable, only needed after paint
default: () => [],
})
// 3. Mutations: plain $fetch in an event handler, then refresh what changed.
async function rename(name: string) {
await $fetch(`/api/users/${route.params.id}`, { method: 'PATCH', body: { name } })
await refresh()
}
</script>
A project $api with auth and error mapping, provided once:
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const session = useUserSession()
const api = $fetch.create({
baseURL: config.public.apiBase,
// On the server, forward the incoming cookies so the API sees the same user.
headers: import.meta.server ? useRequestHeaders(['cookie']) : undefined,
onResponseError({ response }) {
if (response.status === 401) nuxtApp.runWithContext(() => session.clear())
},
})
return { provide: { api } }
})
// useAsyncData wrapper around the provided $api, so pages keep the payload behaviour.
export function useApi<T>(key: string, url: MaybeRefOrGetter<string>, options: Parameters<typeof useAsyncData<T>>[2] = {}) {
const { $api } = useNuxtApp()
return useAsyncData<T>(key, () => $api<T>(toValue(url)), options)
}
A time-based cache across navigations, using getCachedData:
export function useCachedFetch<T>(url: string, ttlMs = 60_000) {
return useFetch<T>(url, {
key: `cached:${url}`,
getCachedData(key, nuxtApp, ctx) {
if (ctx.cause === 'refresh:manual') return undefined // force a real request
const cached = nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
if (!cached) return undefined
const fetchedAt = (cached as { fetchedAt?: number }).fetchedAt ?? 0
return Date.now() - fetchedAt < ttlMs ? cached : undefined
},
transform: (data: T) => ({ ...(data as object), fetchedAt: Date.now() } as T & { fetchedAt: number }),
})
}
const users = await $fetch('/api/users') in <script setup> works, and that is the problem: it runs during SSR, then runs again in the browser during hydration because nothing was stored, and the second result may differ from the first, which is a hydration mismatch on top of a wasted request. Wrap it in useAsyncData.
Sequential await useFetch(...) calls serialise the round trips on the server. Start them without await and await Promise.all([...]), or mark the non-critical ones lazy. The performance section covers the measurement.
Whether pending is still returned alongside status, the current dedupe default, the exact ctx.cause values for getCachedData, and the 4.5 enabled option mentioned in the release notes.
$fetch in a page's setup, open the Network panel with "preserve log", load the page and count the requests. Replace it with useFetch and count again.$api plugin, add a route that requires a cookie, and confirm that the server render sees the user (check the server log) only when headers are forwarded.useCachedFetch, navigate away and back within the TTL, and confirm no request is made; then call refresh() and confirm one is."$fetch is the HTTP client: ofetch, with JSON parsing, error throwing and interceptors, and on the server it calls my own API routes in-process. It belongs in event handlers and server code. useAsyncData wraps any async function so that it runs once on the server, stores the result in the payload under a key, and resolves from that payload during hydration instead of running again; it also gives me status, error and refresh. useFetch is useAsyncData with a $fetch handler and a key derived from the URL. The rule of thumb: composables in setup so data is fetched once and hydration is free, $fetch inside handlers or after user actions."
"During SSR my $fetch calls to the API run on the server, so the browser's cookies are not attached automatically. I either use useRequestFetch(), which returns a $fetch that forwards the incoming request's headers, or pass useRequestHeaders(['cookie']) explicitly, typically inside a project-wide $api created with $fetch.create in a plugin. That plugin is also where I map a 401 to clearing the session. Without it the server renders the anonymous view and the client fetches the authenticated one, which is both a hydration mismatch and a flash of wrong content."
Nothing yet. Notes filed here show up on this page and the section overview.
Routing and pages
File-based routing in Nuxt 4, definePageMeta, layouts, route middleware on server and client, NuxtLink prefetching, typed routes and the route rules that live in Nitro.
State and runtime config
useState for SSR-safe shared state, what the payload can serialise, runtimeConfig versus app.config versus environment variables, useCookie, and when a store is the right tool.