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.

Create a free account to save this page, take notes and track your progress. Sign in

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) and useAsyncData(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 $fetch in 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) or transform to send only what the page needs.
  • Each call returns data, status and error; a failed request doesn't throw, so the page can render a fallback.

The code

Parallel fetches, trimmed payloads and partial failures
// 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/2

Output

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 $fetch directly in <script setup>: it runs on the server and again in the browser. Use useFetch/useAsyncData, and $fetch only in event handlers.
  • Sequential await useFetch calls 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.

Was this page helpful?