Data fetching in Nuxt 3: A Practical Guide

Updated on: 2025-08-20

Nuxt 3 ships three data-fetching composables — useFetch, useAsyncData, and useLazyAsyncData — and picking the wrong one is an easy mistake to make. This guide breaks down what each one does, when to reach for it, and how to avoid the subtle gotchas.

Table of Contents

  1. How Nuxt 3 handles data fetching
  2. useFetch
  3. useAsyncData
  4. useLazyAsyncData
  5. Comparison and best practices
  6. FAQs

How Nuxt 3 handles data fetching

All three composables integrate with SSR — they run on the server, serialize the result, and pass it to the client so you don't re-fetch on hydration. The main difference is whether they block navigation or not.

  • useFetch and useAsyncData both block navigation on client-side transitions until the handler resolves. Good for above-the-fold data you actually need before the page renders.
  • If that blocking behavior isn't what you want — say, for a sidebar widget that loads after the main content — use useLazyAsyncData or pass lazy: true.
  • $fetch alone is fine for event-driven calls like form submissions, but for initial page data you want the SSR hydration and deduplication that the composables give you.

useFetch

useFetch is the go-to for hitting HTTP endpoints. Internally it wraps useAsyncData and $fetch, so you get SSR hydration, deduplication, and type inference from your Nuxt API routes with almost no setup.

Basic usage

const { data, pending, error, refresh } = await useFetch("/api/users");

What it gives you

  • Auto-imports and type hints for your API routes
  • Automatic request deduplication
  • Payload extraction for better performance
  • TypeScript support with automated type inference

Advanced usage

const { data, pending, error, refresh } = await useFetch("/api/comments", {
  method: "post",
  body: { comment: "Great post!" },
  query: { postId: 1 },
  onRequest({ request, options }) {
    options.headers = options.headers || {};
    options.headers.authorization = "...";
  },
  onRequestError({ request, options, error }) {
    // handle request errors
  },
  onResponse({ request, response, options }) {
    // process response data
  },
  onResponseError({ request, response, options }) {
    // handle response errors
  },
});

Common use cases

  1. Fetching a list from your API
const { data: posts } = await useFetch("/api/posts");
  1. Form submissions
const { data, error } = await useFetch("/api/submit", {
  method: "POST",
  body: { name, email, message },
});
  1. Pagination
const page = ref(1);
const { data: paginatedPosts } = await useFetch(
  () => `/api/posts?page=${page.value}&limit=10`,
  { watch: [page] }
);
  1. Polling for live data
const { data: liveData, refresh } = await useFetch("/api/live-data");

const timer = setInterval(() => {
  refresh();
}, 5000);

onScopeDispose(() => {
  clearInterval(timer);
});

useAsyncData

useAsyncData is more flexible — you pass it any async function, not just a fetch call. That makes it the right choice when you're combining multiple sources, using a custom SDK, or shaping the response before it hits your component.

Basic usage

const { data, pending, error, refresh } = await useAsyncData("users", () =>
  $fetch("/api/users")
);

What it gives you

  • Full control over the async logic
  • Works with any async function, not just HTTP
  • Supports lazy loading and server-only execution

useAsyncData blocks navigation until the handler resolves. Pass lazy: true or use useLazyAsyncData if you don't want that.

Common use cases

  1. Fetching two endpoints in parallel
const { data: processedData } = await useAsyncData(
  "processedData",
  async () => {
    const [users, posts] = await Promise.all([
      $fetch("/api/users"),
      $fetch("/api/posts"),
    ]);
    return processData(users, posts);
  }
);
  1. Transforming and caching
const { data: items } = await useAsyncData(
  "items", // this key is used for caching and invalidation
  () => $fetch("/api/items"),
  {
    default: () => [],
    transform: (list) => list.map((i) => ({ id: i.id, name: i.name })),
  }
);
  1. Server-only fetching (for sensitive data)
const { data: sensitiveData } = await useAsyncData(
  "sensitiveData",
  () => $fetch("/api/sensitive-data"),
  { server: true }
);
  1. Merging API and local data
const { data: combinedData } = await useAsyncData("combinedData", async () => {
  const apiData = await $fetch("/api/data");
  const localData = await loadLocalData();
  return mergeData(apiData, localData);
});

Note: the first argument (e.g., "items") is the unique cache key. There's no separate cacheKey option.

useLazyAsyncData

useLazyAsyncData works exactly like useAsyncData but doesn't block navigation — it's useAsyncData with lazy: true baked in. Use it for anything that can load after the page is already visible.

Basic usage

const {
  pending,
  data: users,
  refresh,
} = useLazyAsyncData("users", () => $fetch("/api/users"));

Because it doesn't block navigation, data may be null on first render. Make sure your template handles that.

Common use cases

  1. Non-critical page sections
const { data: additionalInfo } = useLazyAsyncData("additionalInfo", () =>
  $fetch("/api/additional-info")
);
  1. Infinite scroll
const page = ref(1);
const { data: posts, refresh } = useLazyAsyncData(
  "posts",
  () => $fetch(`/api/posts?page=${page.value}`),
  { watch: [page] }
);

const loadMore = () => {
  page.value++;
  refresh();
};
  1. User-triggered loading
const { data: userDetails, execute } = useLazyAsyncData(
  "userDetails",
  () => $fetch(`/api/user/${userId.value}`),
  { immediate: false }
);

const loadUserDetails = () => {
  execute();
};
  1. Conditional fetching
const shouldFetch = ref(false);
const { data: conditionalData } = useLazyAsyncData(
  "conditionalData",
  () => (shouldFetch.value ? $fetch("/api/data") : Promise.resolve(null)),
  { watch: [shouldFetch] }
);

Comparison and best practices

ComposableBest forBlocks navigation?Client behavior
useFetchHTTP endpoints (REST/GraphQL)YesUses cached result, refetches if needed
useAsyncDataCustom async logic, multiple sourcesYesSame as server
useLazyAsyncDataNon-critical or user-triggered dataNoFetches after component mount

A few rules worth keeping in mind

  1. useFetch for HTTP endpoints you need before the page renders
  2. useAsyncData when you're combining sources or need more control
  3. useLazyAsyncData when blocking navigation isn't worth it
  4. Always handle pending and error states — users notice when you don't
  5. After mutations, call refresh() or refreshNuxtData(key) to sync the UI

FAQs

When should I use useFetch over useAsyncData?

Reach for useFetch when you're hitting an HTTP endpoint and want a simple API — it handles SSR hydration and deduplication automatically. Switch to useAsyncData when you need to run arbitrary async logic: fetching from multiple endpoints in parallel, using a third-party SDK, or shaping the payload before it reaches your component.

How does Nuxt handle SSR with these composables?

Nuxt runs the composables on the server, serializes the result into the HTML payload, and passes it to the client. The client picks up that data on hydration instead of making a duplicate request.

Can I use these with external APIs?

Yes, all three composables work with both internal Nuxt API routes and external endpoints.

How do I handle authentication?

Use the onRequest hook to attach auth headers. On the server, pass cookies or headers from the incoming request using useRequestHeaders.

Can I prefetch data for multiple pages?

Yes. You can call these composables in layout files, or wire up custom prefetching logic through Nuxt's router hooks.

Nuxt
Vue
SSR
Web Development