Data fetching
Load page data once on the server with useFetch() and useAsyncData(), send actions with $fetch, and control keys, shallow data, refetching and lazy loading.
What you'll learn
When you finish, you can do these.- 1Load page data once on the server with useFetch()
- 2Refetch a list each time a search box changes
- 3Show a page before its data arrives with lazy: true
- 4Add a todo with $fetch and show it in the list
The Corner Shop, the small online shop this track builds, keeps a todo list for its staff on a page at /todos: buy oat milk, book the van, call the bakery. The list is stored on the server, so the page has to ask for it. Ask in the wrong place, and the page arrives empty while the todos pop in later, or one visit asks for the list twice.
The Code tab holds that page, app/pages/todos.vue, and the server routes it calls: code on the server that answers the address /api/todos. By the end, the page loads its list once, narrows it as you type and adds a todo. You need one idea from the lesson SSR and hydration: when a page first loads, its setup code (<script setup>) runs twice. It runs on the server to build the HTML, then in the browser to hydrate it: to attach the code behind each button to that HTML.
useFetch, useAsyncData and $fetch
Where the page asks for its list decides what the HTML holds. onMounted(), where a plain Vue app often fetches, runs only in the browser after hydration, so the HTML has no todos. $fetch, Nuxt's function for sending a request, is worse at the top of setup: setup runs on the server and again in the browser, so the first load sends two requests.
useFetch() asks once, on the server, and writes the answer into the payload, a script inside the HTML that holds the data the server used. During hydration the browser reads the payload instead of asking again.
useFetch() runscalls the APIdata in payloadwritten into the HTML
hydrateread payloadno second requestdata ready
In the Code tab, todos.vue calls it at the top of its setup with the URL of the server route:
<script setup lang="ts">
const search = ref('')
const { data: todos, status, error } = await useFetch('/api/todos', {
query: { q: search },
})
</script>
It returns data, error and status ('idle', 'pending', 'success' or 'error') as refs, values Vue tracks for changes, plus refresh() to fetch again. data: todos pulls data out under the name todos. await makes setup wait for the answer. The page arrives with its three todos already in the HTML.
useAsyncData(key, handler) is the general form: give it a key, a string that names the result in the payload, and any async function. Use it when the data is not one URL, such as two requests combined. useFetch(url) is nearly useAsyncData(url, () => $fetch(url)), with the key worked out for you.
$fetch on its own is for things the visitor does: nothing renders on the server, so nothing goes in the payload. Under the list, a form binds its input to a ref called title, and its Add button runs add():
async function add() {
const created = await $fetch('/api/todos', { method: 'POST', body: { title: title.value } })
todos.value = [...(todos.value ?? []), created]
title.value = ''
}
Type Water the plants in the New todo box and press Add: the server saves the todo and returns it as created, which goes at the end of the list. The last line empties the box.
What data holds, and when it fetches again
The list in todos changes in two ways: your code replaces it, or Nuxt fetches it again when a ref in the options changes.
data is a shallow ref: Vue tracks when you replace data.value, not changes inside it. That is why add() assigns a new array, the old todos followed by created. Replace the whole value, call refresh(), or pass deep: true if you must change it in place.
The page's search box is bound to the ref search, and the query option adds it to the URL as ?q=…: typing milk asks for /api/todos?q=milk, and the list narrows to Buy oat milk. Because query: { q: search } holds a ref, each change to search sends a new request, and a newer request cancels one still running.
A URL written as a template string, text in backticks with ${} slots, is built once, when setup runs, so `/api/todos?q=${search.value}` never refetches: useFetch gets plain text, not the ref. Pass a getter instead, a function that returns the URL: () => `/api/todos?q=${search.value}`.
Loading without blocking the page
An awaited useFetch also makes a link click wait. After the first load, a click on a <NuxtLink>, Nuxt's link component, renders the next page in the browser: client navigation. The home page, app/pages/index.vue, has one to /todos, and Nuxt waits for the list before it shows the page.
lazy: true in the options shows the page at once, and you show the loading state yourself, from status or an empty data. Until the list arrives, todos is undefined, so the template of todos.vue shows Loading… while todos has no value, then the list.
server: false skips the server entirely: the request runs in the browser after hydration, and nothing lands in the HTML or the payload. Use the two together for data search engines do not need, such as recommendations below an article.
What goes wrong
Both mistakes sit in add(), the function the Add button runs. The first calls useFetch there.
async function add() {
await useFetch('/api/todos', { method: 'POST', body: { title: title.value } })
}
The todo may well be saved, but the console prints [NUXT_E3003]: useAsyncData/useFetch "was called after the component had already mounted, so the data fetch cannot be awaited during setup". useFetch is meant for data the page renders, set up once in setup. A click needs $fetch:
async function add() {
const created = await $fetch('/api/todos', { method: 'POST', body: { title: title.value } })
todos.value = [...(todos.value ?? []), created]
title.value = ''
}
The warning is gone, and the new todo joins the end of the list.
The second mistake pushes created, the new todo, into todos, which is data under another name.
const created = await $fetch('/api/todos', { method: 'POST', body: { title: title.value } })
todos.value?.push(created)
The request succeeds, the array grows, and the list on screen stays the same, with no warning. data is shallow, so Vue never hears about the push. Assign a new array:
todos.value = [...(todos.value ?? []), created]
The new todo appears at the end of the list as soon as the request returns.
What to remember
useFetch()anduseAsyncData()load page data once on the server and pass it to the browser in the payload.- A
querythat holds a ref, or a getter URL, refetches on change; a URL built with a template string does not. lazy: truestops blocking navigation, so show a loading state whiledataisundefined.server: falsemoves the request to the browser.$fetchis for actions such as a form submit.datais shallow: replace it,refresh()it, or ask fordeep: true.
In your project
Do these in a real project. Each one says how to confirm it worked.- Open
/todoswith your browser's Network tab open and typemilkinto the search box. Each keystroke sends one/api/todos?q=…request, and the list narrows. A request still running when you type again shows as cancelled. - Add
lazy: trueto theuseFetchoptions, open/, and click the link to/todos. The page appears at once with Loading…, then fills in. Remove it again: the click now waits until the list is ready. - Load
/todosdirectly and open View Source: the todos sit inside<script type="application/json" id="__NUXT_DATA__">, and the Network tab shows no browser request to/api/todos. Add a lineawait $fetch('/api/todos')to the setup and reload: the browser now requests/api/todostoo, although the server already did.