useFetch vs useAsyncData vs $fetch: Nuxt Datenabruf verstehen

Warum Nuxt drei Wege für HTTP-Requests bietet, wann man welchen einsetzt und welche Fallstricke auf jeden warten.

NuxtVueData FetchingSSR

Warum es drei Optionen gibt

Nuxt bietet drei Wege, um HTTP-Requests zu machen: $fetch, useAsyncData und useFetch. Das ist kein Designfehler — jede Option bedient eine andere Schicht.

$fetch ist der rohe HTTP-Client. useAsyncData ist das SSR-bewusste Wrapper-Composable. useFetch kombiniert beides in einer einfacheren API — mit Fallstricken, die man kennen muss.

Die Verwirrung entsteht, weil alle drei am Ende HTTP-Requests machen. Der Unterschied liegt nicht im "Was", sondern im "Wie und wann": Wer führt den Request aus? Wann? Wie werden die Daten zwischen Server und Client übertragen?

$fetch — der nackte HTTP-Client

$fetch ist Nuxts eigenes fetch-Wrapper, aufgebaut auf ofetch. Es macht genau das, was man erwartet:

const data = await $fetch('/api/posts')

Auf dem Server löst $fetch den Request direkt auf — ohne echten HTTP-Roundtrip, wenn der Endpunkt im selben Nitro-Prozess läuft. Auf dem Client macht es einen normalen HTTP-Request.

Das Problem: $fetch kennt den SSR-Kontext nicht. Wenn man es direkt in <script setup> aufruft, läuft es auf dem Server und auf dem Client. Die Daten werden doppelt geladen — einmal beim Server-Rendering, einmal beim Hydrieren.

// FALSCH: läuft auf Server + Client = doppelter Request
const data = await $fetch('/api/posts')

// RICHTIG: in useAsyncData einwickeln
const { data } = await useAsyncData('posts', () => $fetch('/api/posts'))

Wann $fetch alleine sinnvoll ist: in Event-Handlern, in Server-Routes (server/api/), innerhalb von useAsyncData-Callbacks, oder überall, wo man bewusst außerhalb des SSR-Lifecycles operiert.

useAsyncData — SSR-aware, dedupliziert, gecacht

useAsyncData ist das Herzstück des Nuxt-Datenabrufs. Es macht drei Dinge:

  1. Führt den async Handler nur auf dem Server aus (bei SSR)
  2. Serialisiert das Ergebnis in das HTML-Payload (nuxtApp.payload)
  3. Hydriert den Client mit diesen Daten — ohne einen zweiten Request

Die Signatur:

const { data, status, error, refresh, execute } = await useAsyncData(
  'unique-key',
  () => $fetch('/api/posts'),
  { /* options */ }
)

Der Key ist kritisch — dazu später mehr.

data ist ein Ref<T | null>. status ist 'idle' | 'pending' | 'success' | 'error'. error enthält eventuelle Fetch-Fehler als Ref<FetchError | null>.

const { data: posts, status, error } = await useAsyncData(
  'blog-posts',
  () => $fetch<Post[]>('/api/posts')
)

if (error.value) {
  console.error('Fehler beim Laden:', error.value.message)
}

useAsyncData ist also kein Request-Shortcut — es ist ein Lifecycle-Manager für asynchrone Daten im SSR-Kontext.

useFetch — syntactic sugar mit Fallstricken

useFetch ist nichts anderes als useAsyncData + $fetch in einem Composable:

// Diese beiden sind äquivalent:
const { data } = useFetch('/api/posts')

const { data } = useAsyncData(
  '/api/posts', // URL wird als Key verwendet
  () => $fetch('/api/posts')
)

Mit einem wichtigen Unterschied: useFetch generiert den Key automatisch aus der URL. Das klingt praktisch, ist aber eine häufige Fehlerquelle bei dynamischen URLs.

// FALSCH: URL ist statisch — reagiert nicht auf Änderungen
const { data } = useFetch(`/api/posts/${route.params.id}`)

// RICHTIG: URL als Funktion übergeben, damit sie reaktiv ist
const { data } = useFetch(() => `/api/posts/${route.params.id}`)

Wenn man die URL als String literal übergibt, wird sie zum Zeitpunkt des Aufrufens ausgewertet und danach nicht mehr aktualisiert. Ändert sich route.params.id, passiert nichts — data bleibt beim ersten Wert.

useFetch unterstützt dieselben Optionen wie useAsyncDatawatch, transform, pick, getCachedData, lazy, server usw.

Der Key: Deduplication und warum er korrekt sein muss

useAsyncData und useFetch deduplizieren Requests anhand des Keys. Zwei Komponenten oder Composables mit demselben Key teilen dieselben Daten und lösen keinen zweiten Request aus.

Das ist gut für Performance — aber ein falscher Key bricht diese Deduplication still und heimlich.

// BUG: Beide Calls haben denselben Key, aber laden unterschiedliche Posts
const { data: post1 } = useAsyncData('post', () => $fetch('/api/posts/1'))
const { data: post2 } = useAsyncData('post', () => $fetch('/api/posts/2'))

// post2.value === post1.value — post2 bekommt nie seine eigenen Daten

Der Key muss alle Variablen enthalten, die die Daten eindeutig identifizieren:

const id = computed(() => route.params.id)

const { data: post } = useAsyncData(
  `post-${id.value}`, // Key enthält die ID
  () => $fetch(`/api/posts/${id.value}`)
)

Bei useFetch wird die URL als Key verwendet — daher ist es wichtig, dynamische Werte direkt in der URL-Funktion einzubauen, nicht als separate Query-Parameter außerhalb.

Hydration: Warum Daten client-seitig nicht neu geladen werden

Das ist der entscheidende Mechanismus, der alles zusammenhält. Bei SSR läuft folgendes ab:

  1. Server führt den useAsyncData-Handler aus
  2. Daten werden in nuxtApp.payload serialisiert (als JSON in das HTML eingebettet)
  3. Browser lädt das HTML — inklusive Payload
  4. Vue hydriert: useAsyncData findet die Daten im Payload und löst keinen neuen Request aus

Das bedeutet: Auf dem Client wird kein zweiter Request gemacht. Die Daten sind bereits im HTML vorhanden.

Dieses Verhalten lässt sich gezielt überschreiben:

const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
  server: false, // Nur auf dem Client ausführen — kein SSR-Rendering
  lazy: true, // Nicht auf den Handler warten — Seite rendert sofort
})

server: false ist sinnvoll für Daten, die keinen SEO-Einfluss haben — zum Beispiel user-spezifische Inhalte hinter einem Login.

lazy: true ist sinnvoll, wenn die Seite sofort rendern soll und die Daten nachgeladen werden — ähnlich wie ein Skeleton-Loading-Pattern.

watch, refresh() und Navigation-Refetching

useAsyncData reagiert auf reaktive Abhängigkeiten über die watch-Option:

const filter = ref('all')

const { data, refresh } = useAsyncData(
  'posts',
  () => $fetch('/api/posts', { query: { filter: filter.value } }),
  { watch: [filter] } // Automatisch neu laden wenn filter sich ändert
)

Alternativ kann man refresh() manuell aufrufen — zum Beispiel nach einer User-Aktion:

async function handleFilterChange(newFilter: string) {
  filter.value = newFilter
  await refresh()
}

Standardmäßig werden useAsyncData-Calls bei Navigation nicht neu ausgeführt, wenn der Key gleich bleibt. Das ist bewusst — Nuxt nutzt den gecachten Wert. Um bei jeder Navigation neu zu laden, kann man die Route in watch aufnehmen oder execute() direkt in einem Navigation-Guard aufrufen.

getCachedData für SWR-Patterns

getCachedData erlaubt feinere Kontrolle über das Caching-Verhalten im App-Layer. Die Funktion bekommt den Key und die nuxtApp-Instanz und kann gecachte Daten zurückgeben, bevor der Handler ausgeführt wird:

const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
  getCachedData(key, nuxtApp) {
    // Gecachte Daten aus dem Payload nehmen, wenn vorhanden
    return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
  }
})

Ein reales Stale-While-Revalidate-Pattern: Daten sofort zeigen (gecacht), im Hintergrund aktualisieren:

const { data } = useAsyncData('posts', () => $fetch('/api/posts'), {
  getCachedData(key, nuxtApp) {
    const cached = nuxtApp.payload.data[key]
    if (cached) {
      // Gecachte Daten sofort zurückgeben — Fetch findet trotzdem statt
      return cached
    }
  }
})

getCachedData ist der richtige Weg für SWR im Nuxt App-Layer — nicht zu verwechseln mit dem Nitro-Level-Caching über routeRules.

transform und pick — Payload klein halten

Das Nuxt-Payload wird als JSON in das initiale HTML eingebettet. Große API-Antworten verlangsamen Download und Parsing.

transform lässt einen die Antwort transformieren, bevor sie im Payload gespeichert wird:

const { data: titles } = useAsyncData(
  'post-titles',
  () => $fetch<Post[]>('/api/posts'),
  {
    transform(posts) {
      // Nur die Titel behalten — der Rest landet nie im Payload
      return posts.map(p => ({ id: p.id, title: p.title }))
    }
  }
)

pick macht dasselbe deklarativ auf Property-Ebene:

const { data: posts } = useFetch('/api/posts', {
  pick: ['id', 'title', 'date'] // Nur diese Felder im Payload
})

Beide Optionen reduzieren die Payload-Größe und verbessern die Zeit bis zum First Contentful Paint — besonders relevant bei Seiten mit vielen und großen Datenobjekten.

Wann server-only fetching der richtige Weg ist

Manchmal sollen Daten ausschließlich auf dem Server abgerufen werden — zum Beispiel wenn API-Keys im Spiel sind, die niemals den Client erreichen dürfen.

const { data } = useAsyncData('protected-data', async () => {
  // Dieser Code läuft nur auf dem Server
  // Der API-Key ist sicher — wird nie im Client-Bundle eingebettet
  return await $fetch('https://api.example.com/data', {
    headers: {
      Authorization: `Bearer ${process.env.SECRET_API_KEY}`
    }
  })
})

Mit server: true (Standard) und ohne lazy: true wird der Handler ausschließlich auf dem Server ausgeführt. Der Client hydriert mit den serialisierten Daten — der API-Key taucht nirgendwo im Client-Code auf.

Wenn man hingegen nach User-Interaktion Daten neu laden muss, kommt $fetch in einem Event-Handler zum Einsatz — bewusst außerhalb des SSR-Lifecycles, im Wissen, dass dieser Request ausschließlich im Browser stattfindet.

async function loadMore() {
  // In Event-Handlern: $fetch direkt, kein useAsyncData
  const more = await $fetch('/api/posts', {
    query: { page: currentPage.value + 1 }
  })
  posts.value.push(...more)
  currentPage.value++
}

Die Faustregel: useAsyncData/useFetch für initiales Laden mit SSR-Support, $fetch für alles, was nach User-Interaktion passiert.

Weiterführend

Alexander Lichter — Nuxt-Core-Team-Mitglied und einer der besten Erklärer rund um das Framework — hat das Thema in diesem Video auf den Punkt gebracht: