Modul 8: Nuxt Grundlagen

Data Fetching

Daten vom Server laden — mit useFetch(), useAsyncData() und $fetch. Wie Controller-Actions in Rails, aber reaktiv und gecacht.

Daten laden in Nuxt

In Rails lädst du Daten im Controller und übergibst sie an die View. In Nuxt passiert das direkt in der Seite — mit speziellen Composables, die SSR, Caching und Reaktivität automatisch handhaben.

🛤️

Rails-Vergleich: Controller → View

Rails: def index; @posts = Post.all; end → View rendert @posts
Nuxt: const { data: posts } = await useFetch('/api/posts') → Template rendert posts
Der Unterschied: In Nuxt ist alles in einer Datei, und die Daten sind reaktiv.

MethodeWann verwenden?
useFetch()Standard — für die meisten API-Calls
useAsyncData()Wenn du komplexe Logik brauchst
$fetch()Nur Client — z.B. Button-Clicks, Formulare

useFetch() — Das Schweizer Taschenmesser

useFetch() ist dein Hauptwerkzeug. Es kombiniert Datenladen mit SSR-Support, Caching und Reaktivität — alles in einem Aufruf.

pages/posts/index.vue
<template>
  <div>
    <!-- Ladezustand -->
    <div v-if="status === 'pending'">Lade Posts...</div>

    <!-- Fehlerfall -->
    <div v-else-if="error">
      Fehler: {{ error.message }}
    </div>

    <!-- Daten anzeigen -->
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        <NuxtLink :to="'/posts/' + post.slug">
          {{ post.title }}
        </NuxtLink>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
// useFetch — wie @posts = Post.all im Rails-Controller
const {
  data: posts,
  error,
  status
} = await useFetch('/api/posts')
</script>

Was useFetch() zurückgibt

useFetch Rückgabewerte
const {
  data,      // Ref<T | null> — die geladenen Daten
  error,     // Ref<Error | null> — Fehler, falls aufgetreten
  status,    // Ref<'idle' | 'pending' | 'success' | 'error'>
  refresh,   // () => Promise — Daten neu laden
  execute,   // () => Promise — manuell ausführen (bei immediate: false)
  clear      // () => void — Daten und Fehler zurücksetzen
} = await useFetch('/api/posts')
💡

SSR-Deduplizierung

useFetch() lädt die Daten auf dem Server, rendert HTML und schickt die Daten als Payload mit. Der Browser muss den API-Call nicht wiederholen. In Rails ist das automatisch (Server rendert immer), in SPAs ein häufiges Problem — Nuxt löst es elegant.

useFetch() mit Optionen

useFetch() akzeptiert viele Optionen für Query-Parameter, HTTP-Methoden, Header und Daten-Transformation.

useFetch mit Optionen
<script setup lang="ts">
// GET mit Query-Parametern
const page = ref(1)
const { data: posts } = await useFetch('/api/posts', {
  query: { page, limit: 10 }  // → /api/posts?page=1&limit=10
  // Reaktiv! Ändert sich page, werden Daten neu geladen
})

// POST mit Body
const { data: result } = await useFetch('/api/posts', {
  method: 'POST',
  body: { title: 'Neuer Post', content: '...' }
})

// Mit Headern und Transformation
const { data: userNames } = await useFetch('/api/users', {
  headers: { 'Accept-Language': 'de' },
  // Daten vor dem Cachen transformieren
  transform: (users) => users.map(u => u.name)
})

// Nur bestimmte Felder aus der Antwort
const { data: titles } = await useFetch('/api/posts', {
  pick: ['id', 'title']  // Reduziert den Payload
})
</script>

useAsyncData() — Volle Kontrolle

Wenn du mehr Kontrolle brauchst oder mehrere API-Calls kombinieren willst, nutze useAsyncData(). Es ist die Basis unter useFetch().

useAsyncData Beispiel
<script setup lang="ts">
// Mehrere API-Calls kombinieren
const { data: dashboardData } = await useAsyncData(
  'dashboard',  // Eindeutiger Cache-Key
  async () => {
    // Parallele Requests — wie Promise.all
    const [posts, users, stats] = await Promise.all([
      $fetch('/api/posts'),
      $fetch('/api/users'),
      $fetch('/api/stats')
    ])

    return { posts, users, stats }
  }
)

// Zugriff: dashboardData.value.posts
//          dashboardData.value.users
//          dashboardData.value.stats

// In Rails wäre das:
// def dashboard
//   @posts = Post.recent
//   @users = User.active
//   @stats = Stats.current
// end
</script>
ℹ️

useFetch = useAsyncData + $fetch

useFetch(url) ist im Grunde nur eine Kurzform für useAsyncData(() => $fetch(url)). Wenn du nur einen API-Endpunkt aufrufst, nimm useFetch. Für alles Komplexere nimm useAsyncData.

$fetch — Für Client-Aktionen

Für Aktionen, die nicht beim Seitenaufbau passieren — Button-Clicks, Formular-Submissions, Löschvorgänge — nutzt du $fetch() direkt.

$fetch für Aktionen
<template>
  <div>
    <h2>Neuen Post erstellen</h2>
    <form @submit.prevent="createPost">
      <input v-model="title" placeholder="Titel" />
      <textarea v-model="content" placeholder="Inhalt" />
      <button type="submit" :disabled="saving">
        {{ saving ? 'Speichern...' : 'Erstellen' }}
      </button>
    </form>
  </div>
</template>

<script setup lang="ts">
const title = ref('')
const content = ref('')
const saving = ref(false)

async function createPost() {
  saving.value = true

  try {
    // $fetch für einmalige Aktionen
    const post = await $fetch('/api/posts', {
      method: 'POST',
      body: {
        title: title.value,
        content: content.value
      }
    })

    // Nach Erfolg: Weiterleiten
    navigateTo('/posts/' + post.slug)
  } catch (error) {
    console.error('Erstellen fehlgeschlagen:', error)
  } finally {
    saving.value = false
  }
}
</script>
⚠️

$fetch nicht im Setup verwenden!

Verwende $fetch() nicht im <script setup> Top-Level für initiale Daten. Das würde den Request sowohl auf dem Server als auch im Browser ausführen — doppelte Arbeit! Für initiale Daten immer useFetch() oder useAsyncData().

Lazy Fetching

Standardmäßig blockiert useFetch() die Navigation, bis die Daten geladen sind. Mit lazy: true wird die Seite sofort gerendert und die Daten nachgeladen.

Lazy Fetching
<script setup lang="ts">
// Standard: Blockiert Navigation bis Daten da sind
const { data: posts } = await useFetch('/api/posts')

// Lazy: Seite wird sofort gerendert, Daten kommen nach
const { data: comments, status } = useFetch('/api/comments', {
  lazy: true
})
// Hinweis: Kein 'await' bei lazy!

// Oder als Shortcut:
const { data: tags } = useLazyFetch('/api/tags')
</script>

<template>
  <div>
    <!-- Posts sind sofort da (await) -->
    <PostList :posts="posts" />

    <!-- Kommentare laden nach -->
    <div v-if="status === 'pending'">
      Lade Kommentare...
    </div>
    <CommentList v-else :comments="comments" />
  </div>
</template>
🛤️

Rails-Vergleich: Turbo Frames

Lazy Fetching ist vergleichbar mit Turbo Frames in Rails, die Teile einer Seite nachladen: <turbo-frame src="/posts" loading="lazy">. In Nuxt ist es ein einfacher Parameter — kein HTML-Attribut, kein separater Request/Response-Cycle.

Refresh & Execute

Daten neu laden — nach einer Aktion, einem Timer oder einer Benutzerinteraktion:

Daten aktualisieren
<template>
  <div>
    <button @click="refresh()">
      🔄 Aktualisieren
    </button>

    <ul>
      <li v-for="post in posts" :key="post.id">
        {{ post.title }}
        <button @click="deleteAndRefresh(post.id)">
          Löschen
        </button>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
const { data: posts, refresh } = await useFetch('/api/posts')

async function deleteAndRefresh(id: number) {
  // Post löschen
  await $fetch(`/api/posts/${id}`, { method: 'DELETE' })

  // Liste neu laden
  await refresh()
}

// Auch nützlich: Daten beim Fokus-Wechsel neu laden
// const { data } = await useFetch('/api/notifications', {
//   watch: [() => document.hasFocus()]
// })
</script>

Error Handling

useFetch() gibt Fehler als reaktiven Wert zurück. Kein try/catch nötig — du kannst den Fehler direkt im Template anzeigen.

Fehlerbehandlung
<template>
  <div>
    <!-- Globaler Fehler -->
    <div v-if="error"
      class="p-4 rounded-lg bg-red-100 border border-red-300">
      <h3 class="font-bold text-red-800">Fehler beim Laden</h3>
      <p class="text-red-700">{{ error.message }}</p>
      <button @click="refresh()"
        class="mt-2 px-4 py-2 bg-red-600 text-white rounded">
        Erneut versuchen
      </button>
    </div>

    <!-- Daten -->
    <div v-else-if="post">
      <h1>{{ post.title }}</h1>
      <div v-html="post.content" />
    </div>
  </div>
</template>

<script setup lang="ts">
const route = useRoute()

const { data: post, error, refresh } = await useFetch(
  `/api/posts/${route.params.id}`,
  {
    // Custom Error-Handling
    onResponseError({ response }) {
      if (response.status === 404) {
        showError({ statusCode: 404, statusMessage: 'Post nicht gefunden' })
      }
    }
  }
)
</script>

Caching-Verhalten

Nuxt cached Daten automatisch mit einem eindeutigen Key. Wenn du dieselbe URL auf mehreren Seiten verwendest, wird nur einmal geladen.

Caching konfigurieren
// Automatischer Cache-Key (basiert auf URL + Params)
const { data } = await useFetch('/api/posts')

// Eigener Cache-Key
const { data } = await useFetch('/api/posts', {
  key: 'all-posts'
})

// Cache umgehen
const { data } = await useFetch('/api/posts', {
  key: 'posts-fresh',
  getCachedData: () => undefined  // Nie aus Cache lesen
})

// Reaktive Parameter → automatische neue Requests
const page = ref(1)
const { data: posts } = await useFetch('/api/posts', {
  query: { page }
  // page.value = 2 → neuer Request automatisch!
})
💡

Cache-Keys verstehen

Der Cache-Key wird automatisch aus der URL und den Parametern generiert. Wenn du reaktive Parameter verwendest (z.B. query: { page }), werden neue Daten bei Änderung automatisch geladen. Wie Rails.cache.fetch("posts-page-#{params[:page]}").

Praxis-Beispiel: Posts laden und anzeigen

Ein komplettes Beispiel, das Posts von einer API lädt, Fehler behandelt und eine Lade-Anzeige zeigt:

pages/posts/index.vue
<template>
  <div class="max-w-4xl mx-auto">
    <h1 class="text-3xl font-bold mb-6">Blog</h1>

    <!-- Suchfeld -->
    <input
      v-model="search"
      type="search"
      placeholder="Posts durchsuchen..."
      class="w-full mb-6 px-4 py-2 rounded-lg border"
    />

    <!-- Ladezustand -->
    <div v-if="status === 'pending'"
      class="text-center py-12 text-gray-500">
      <p>Lade Posts...</p>
    </div>

    <!-- Fehlerzustand -->
    <div v-else-if="error"
      class="text-center py-12">
      <p class="text-red-600 mb-4">{{ error.message }}</p>
      <button @click="refresh()"
        class="px-4 py-2 bg-blue-600 text-white rounded">
        Erneut versuchen
      </button>
    </div>

    <!-- Posts-Liste -->
    <div v-else class="space-y-4">
      <article v-for="post in posts" :key="post.id"
        class="p-6 rounded-xl border hover:border-blue-300 transition">
        <NuxtLink :to="'/posts/' + post.slug">
          <h2 class="text-xl font-semibold mb-2">
            {{ post.title }}
          </h2>
          <p class="text-gray-600">{{ post.excerpt }}</p>
          <time class="text-sm text-gray-400 mt-2 block">
            {{ new Date(post.createdAt).toLocaleDateString('de-DE') }}
          </time>
        </NuxtLink>
      </article>

      <!-- Pagination -->
      <div class="flex justify-between mt-8">
        <button @click="page--" :disabled="page <= 1">
          ← Zurück
        </button>
        <span>Seite {{ page }}</span>
        <button @click="page++">
          Weiter →
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
const search = ref('')
const page = ref(1)

const { data: posts, error, status, refresh } = await useFetch(
  '/api/posts',
  {
    query: { page, search },  // Reaktiv!
    watch: [search]           // Bei Suche neu laden
  }
)
</script>
server/api/posts.get.ts
// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
  const { page = '1', limit = '10', search = '' } = getQuery(event)

  // In einer echten App: Datenbank-Abfrage
  // Wie PostsController#index in Rails
  let posts = [
    { id: 1, title: 'Einführung in Nuxt 3', slug: 'einfuehrung-nuxt-3',
      excerpt: 'Alles über das Vue-Meta-Framework', createdAt: '2025-01-15' },
    { id: 2, title: 'Vue Composition API', slug: 'vue-composition-api',
      excerpt: 'Reactive State Management leicht gemacht', createdAt: '2025-01-10' },
    { id: 3, title: 'Server Routes erklärt', slug: 'server-routes',
      excerpt: 'API-Endpunkte direkt in Nuxt', createdAt: '2025-01-05' },
  ]

  // Suche filtern
  if (search) {
    posts = posts.filter(p =>
      p.title.toLowerCase().includes(String(search).toLowerCase())
    )
  }

  // Pagination
  const start = (Number(page) - 1) * Number(limit)
  return posts.slice(start, start + Number(limit))
})
🛤️

Der komplette Vergleich

In Rails wäre das:
PostsController#index@posts = Post.page(params[:page])
index.html.erb → HTML-Template mit @posts.each
In Nuxt passiert beides in einer Datei: Datenladen + Template. Die API-Route in server/api/ ersetzt den Rails-Controller.

💡

Nächster Schritt

Du weißt jetzt, wie du Daten vom Server holst. Im nächsten Kapitel lernst du, wie du selbst Server-Routen (APIs) baust — das Backend in deiner Nuxt-App.