Vue 3 and Nuxt · Building Apps with Nuxt
Data Fetching with useFetch and useAsyncData
Fetch data once on the server and reuse it in the browser with useFetch and useAsyncData, load in parallel, trim payloads, and handle errors.
What you'll build
A product page that loads the product and its reviews in parallel from the app's own API, trims the data sent to the browser with transform, and keeps working when the reviews service fails. Server logs show each API handler runs once per page render.
The idea
useFetch(url)anduseAsyncData(key, fn)run during server rendering and put the result in the page payload; the browser hydrates with it instead of fetching again. A plain$fetchin setup would run twice (server and browser).- Independent requests should start together:
await Promise.all([useFetch(a), useFetch(b)]). - Everything returned is serialized into the HTML; use
pick(objects) ortransformto send only what the page needs. - Each call returns
data,statusanderror; a failed request doesn't throw, so the page can render a fallback.
The code
// file: nuxt.config.ts
export default defineNuxtConfig({ compatibilityDate: "2026-10-01", devtools: { enabled: false } });
// file: app/app.vue
<template>
<main><NuxtPage /></main>
</template>
// file: server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
console.log(`[app] products api started (${id})`);
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(`[app] products api done (${id})`);
return { id, name: "Teapot", price: 24, supplierCost: 9.1, warehouseNotes: "Shelf B4" };
});
// file: server/api/reviews/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
console.log(`[app] reviews api started (${id})`);
await new Promise((resolve) => setTimeout(resolve, 50));
if (id === "2") throw createError({ statusCode: 503, statusMessage: "Reviews service unavailable" });
return [{ author: "Ana", stars: 5 }, { author: "Ben", stars: 4 }];
});
// file: app/pages/products/[id].vue
<script setup lang="ts">
const id = useRoute().params.id as string;
const [{ data: product }, { data: reviews, error: reviewsError }] = await Promise.all([
useFetch(`/api/products/${id}`, {
transform: ({ name, price }) => ({ name, price }), // the browser never sees cost or notes
}),
useFetch(`/api/reviews/${id}`),
]);
</script>
<template>
<h1>{{ product?.name }}</h1>
<p>${{ product?.price.toFixed(2) }}</p>
<p v-if="reviewsError">Reviews are unavailable right now.</p>
<ul v-else>
<li v-for="r in reviews" :key="r.author">{{ r.author }}: {{ r.stars }} stars</li>
</ul>
</template>
// request: GET /products/1 find:supplierCost
// request: GET /products/2Output
GET /products/1 -> 200 [app] products api started (1) [app] reviews api started (1) [app] products api done (1) Teapot $24.00 Ana: 5 stars Ben: 4 stars response contains "supplierCost": no GET /products/2 -> 200 [app] products api started (2) [app] reviews api started (2) [app] reviews api started (2) [app] products api done (2) Teapot $24.00 Reviews are unavailable right now.
Run this on your computer — see “Get Started” for setup.
Both APIs start before either finishes. The supplier cost isn't in the response because transform ran on the server before the payload was serialized. On /products/2 the reviews call fails but the product still renders. Notice the reviews API was called twice: useFetch (through ofetch) retries a failed GET once by default; set retry: 0 or tune retry/retryDelay for slow dependencies.
Common mistakes
- Calling
$fetchdirectly in<script setup>: it runs on the server and again in the browser. UseuseFetch/useAsyncData, and$fetchonly in event handlers. - Sequential
await useFetchcalls for independent data, creating waterfalls. - Returning whole records with internal fields; they're serialized into every page.
- Reusing the same key for different data in
useAsyncData, so pages show each other's results.
Key points
- useFetch and useAsyncData fetch once on the server and hydrate from the payload.
- Start independent requests together with Promise.all.
- transform/pick trim payloads; errors are returned, not thrown.
Further reading: Data fetching · useFetch · useAsyncData
Practice questions
Why use useFetch instead of $fetch in a page's setup?
Answer
useFetch runs on the server, stores the result in the payload and reuses it during hydration; $fetch in setup would fetch again in the browser.
How do you keep internal fields out of the page payload?
Answer
Use the pick or transform options so only the needed fields are stored in the payload, or return DTOs from the API.
How do you load two independent resources without a waterfall?
Answer
Start both and await them together, for example await Promise.all([useFetch(a), useFetch(b)]).
Open in the app to mark lessons complete, take notes and track your progress.