Design wechseln

Next.js Server Components Datenabruf – vollständiger Leitfaden: fetch, Datenbankabfragen und Best Practices

Easton editorial illustration: route-map drafting table

Beim ersten Schreiben einer Komponente im Next.js App Router sah der Code so aus:

async function Page() {
  const data = await fetch('...')
  return <div>{data}</div>
}

So einfach? Direkt await? Kein useEffect, kein useState, keine Sorge wegen Race Conditions?

Das war verwirrend. Gewohnt an das clientseitige React-Muster hört man plötzlich: „Komponenten können async sein“ – als hätten sich die Regeln geändert. Noch unklarer: Soll man die fetch-API nutzen oder direkt die Datenbank ansprechen? Mit fetch wirkt es wie ein zusätzlicher API-Aufruf; mit direkter DB-Abfrage fürchtet man, Geheimnisse könnten in den Client gelangen.

Wenn Sie dieselben Fragen haben, ist dieser Artikel für Sie. Wir gehen die richtige Vorgehensweise für Datenabruf in Next.js Server Components durch – wann fetch, wann Datenbank, wie async-Komponenten aussehen, wie Caching steuert wird und welche Fallstricke häufig sind.

Grundlagen: Datenabruf in Server Components

Warum können Server Components direkt await nutzen?

Kurz: Server Components laufen auf dem Server, nicht im Browser.

Das klingt trivial, ist aber der entscheidende Unterschied. Klassische React-Komponenten rendern im Browser und können weder direkt auf eine Datenbank noch auf das Dateisystem zugreifen. Server Components werden auf dem Server ausgeführt – also alles, was früher nur in API-Routen ging:

  • Direkte Datenbankverbindung (Prisma, Drizzle, natives SQL)
  • Dateisystem lesen (z. B. Markdown-Dateien)
  • Interne Dienste aufrufen (ohne CORS-Probleme)
  • Umgebungsvariablen und Secrets nutzen (nicht an den Client gesendet)

Dieser Code ist deshalb sicher:

// app/posts/page.tsx
import { db } from '@/lib/db'

async function PostsPage() {
  // Direkte DB-Abfrage – Secrets gehen nicht in den Browser
  const posts = await db.post.findMany()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

export default PostsPage

Wichtige Punkte:

  1. Komponente als async function deklarieren
  2. Datenbankabfrage direkt mit await
  3. Keine React-Hooks (useState, useEffect usw.)
  4. Standardmäßig Server-Rendering; der Client erhält vor allem HTML

Drei Hauptwege für Datenabruf

In Server Components stehen drei Optionen zur Verfügung:

1. fetch-API

Bekanntes Muster – externe APIs oder eigene Route Handler:

async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return <div>{data.title}</div>
}

2. Direkte Datenbankabfrage

ORM oder DB-Client:

import { db } from '@/lib/db'

async function Page() {
  const data = await db.posts.findFirst()
  return <div>{data.title}</div>
}

3. Server Actions

Für Datenänderungen (Formulare, Löschen usw.) – nicht nur lesen:

async function createPost(formData: FormData) {
  'use server'
  const title = formData.get('title')
  await db.post.create({ data: { title } })
}

Welche Option wann? Das folgt im nächsten Abschnitt.

fetch vs. Datenbankabfrage – wie entscheiden?

Am Anfang mit dem App Router war das die größte Unsicherheit. Im Rückblick ist die Antwort klar.

Entscheidungsbaum: in wenigen Sekunden

Drei Fragen:

  1. Ist es eine Server Component? → Ja: weiter; nein (Client Component): zu Frage 3
  2. Woher kommen die Daten?
    • Eigene Datenbank → direkt abfragen
    • Drittanbieter-API → fetch
  3. Braucht eine Client Component die Daten? → API-Route anlegen, dann fetch

Warum zuerst direkt die Datenbank?

Die Next.js-Empfehlung ist eindeutig: In Server Components nicht unnötig über API-Routen gehen – direkt abfragen.

1. Ein HTTP-Hop weniger

// ❌ Umweg: Server Component → API Route → Database
async function Page() {
  const res = await fetch('/api/posts')  // HTTP-Aufruf
  const posts = await res.json()
  return <PostList posts={posts} />
}

// ✅ Direkt: Server Component → Database
async function Page() {
  const posts = await db.post.findMany()  // Direktabfrage
  return <PostList posts={posts} />
}

Variante zwei spart eine Schicht – oft 100–200 ms schneller, spürbar für Nutzer.

2. Bessere Typsicherheit

Mit TypeScript + Prisma/Drizzle liefert die direkte Abfrage vollständige Typableitung:

// Typen automatisch, gute Editor-Unterstützung
const post = await db.post.findFirst({
  include: { author: true, comments: true }
})

// post.author.name ← typisiert
// post.comments[0].content ← ebenfalls

Bei fetch brauchen Sie manuelle Typen oder as-Assertions – fehleranfälliger.

3. Weniger Code

Keine extra API-Datei, kein HTTP-Status-Handling – oft die Hälfte an Boilerplate.

Wann API/fetch trotzdem nötig ist

Fall 1: Client Component braucht Daten

Client-Komponenten laufen im Browser – dort keine direkte DB. API-Endpunkt nötig:

// app/api/posts/route.ts
export async function GET() {
  const posts = await db.post.findMany()
  return Response.json(posts)
}

// components/client-posts.tsx
'use client'
export function ClientPosts() {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(setPosts)
  }, [])

  return <div>{/* posts rendern */}</div>
}

Fall 2: Öffentliche API für andere Dienste

Mobile Apps oder Dritte brauchen einen dokumentierten Endpunkt.

Fall 3: Drittanbieter-Dienste

GitHub API, OpenAI API usw. – fetch:

async function Page() {
  const res = await fetch('https://api.github.com/users/vercel')
  const user = await res.json()
  return <div>Followers: {user.followers}</div>
}

async/await-Komponenten richtig schreiben

Basismuster: überraschend einfach

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id }
  })

  if (!product) {
    return <div>Product not found</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
    </div>
  )
}

Kein Loading-State, kein useEffect – Daten warten, dann rendern. Klar und linear.

Parallel vs. seriell: großer Performance-Unterschied

// ❌ Seriell: langsam
async function Page() {
  const user = await db.user.findFirst()      // ~200 ms
  const posts = await db.post.findMany()      // +150 ms
  const comments = await db.comment.findMany()  // +100 ms
  // Summe ~450 ms
  return <Dashboard user={user} posts={posts} comments={comments} />
}

// ✅ Parallel: schnell
async function Page() {
  const [user, posts, comments] = await Promise.all([
    db.user.findFirst(),
    db.post.findMany(),
    db.comment.findMany(),
  ])
  // ~200 ms (langsamster Request)
  return <Dashboard user={user} posts={posts} comments={comments} />
}

Ohne Abhängigkeiten zwischen Queries: Promise.all. Bei Abhängigkeit seriell:

// Seriell nötig: zweite Query braucht Ergebnis der ersten
async function Page({ params }) {
  const user = await db.user.findUnique({ where: { id: params.id } })
  const posts = await db.post.findMany({ where: { authorId: user.id } })
  return <Profile user={user} posts={posts} />
}

Suspense-Grenzen: Ladeerlebnis steuern

„Daten werden auf dem Server geladen – sieht der Nutzer nur Weiß?“

Teilweise – aber loading.js und Suspense helfen.

Methode 1: loading.js

loading.tsx im Routenordner:

// app/posts/loading.tsx
export default function Loading() {
  return <div>Loading posts...</div>
}

// app/posts/page.tsx
async function PostsPage() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

Zuerst „Loading posts…“, dann der echte Inhalt.

Methode 2: manuelles Suspense

import { Suspense } from 'react'

async function SlowComponent() {
  const data = await slowQuery()
  return <div>{data}</div>
}

async function FastComponent() {
  const data = await fastQuery()
  return <div>{data}</div>
}

export default function Page() {
  return (
    <div>
      <FastComponent />
      <Suspense fallback={<div>Loading...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Häufiger Fehler: Suspense an der falschen Stelle

// ❌ Suspense innerhalb der async-Komponente – wirkt nicht
async function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {await slowQuery()}
    </Suspense>
  )
}

// ✅ Suspense umschließt die async-Komponente von außen
export default function Layout() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <SlowPage />
    </Suspense>
  )
}

Suspense muss außerhalb der async-Komponente sitzen, um das Promise abzufangen.

Request-Deduplizierung

Derselbe Request mehrfach in einem Render – Next.js dedupliziert automatisch:

async function Header() {
  const user = await db.user.findFirst()
  return <div>{user.name}</div>
}

async function Sidebar() {
  const user = await db.user.findFirst()  // wird nicht erneut ausgeführt
  return <div>{user.name}</div>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
      {/* effektiv nur eine DB-Abfrage */}
    </div>
  )
}

Mehrere Komponenten können dieselbe Datenquelle nutzen, ohne Performance-Probleme.

Caching und Revalidierung

In Next.js 15 hat sich hier viel geändert – viele sind ins Stolpern geraten.

Standard-Caching in Next.js 15

Früher (Next.js 14): fetch standardmäßig cache: 'force-cache'.

Jetzt (Next.js 15): fetch standardmäßig cache: 'no-store' – kein Cache, jedes Mal neu.

Grund: Viele erwarteten Live-Daten, bekamen aber gecachte alte Werte. Explizites Caching passt besser zur Mentalität „Daten bewusst cachen“.

Nach Upgrade von 14 auf 15 kann die Seite langsamer wirken – früher gecachte Requests laufen jetzt jedes Mal.

Drei Caching-Strategien

1. Vollständig cachen (statische Inhalte)

async function BlogPost({ slug }) {
  const post = await fetch(`https://api.example.com/posts/${slug}`, {
    cache: 'force-cache'
  })
  return <article>{post.content}</article>
}

Für Blogposts, Produktseiten, Docs – selten ändernde Inhalte.

2. Kein Cache (Echtzeitdaten)

async function StockPrice() {
  const price = await fetch('https://api.example.com/stock', {
    cache: 'no-store'
  })
  return <div>Current price: {price}</div>
}

Aktienkurse, Live-Kommentare, Nutzerstatus.

3. Zeitgesteuerte Revalidierung (ISR)

async function ProductList() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }
  })
  return <div>{products.map(p => <Card key={p.id} {...p} />)}</div>
}

Produktlisten, News-Startseiten – kurze Verzögerung ok.

Manuelle Revalidierung nach Datenänderung

1. revalidatePath (ganze Route)

'use server'
import { revalidatePath } from 'next/cache'

async function createPost(formData: FormData) {
  await db.post.create({ data: {...} })
  revalidatePath('/posts')
}

2. revalidateTag (feiner)

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  })
  return res.json()
}

'use server'
import { revalidateTag } from 'next/cache'

async function createPost() {
  await db.post.create({ data: {...} })
  revalidateTag('posts')
}

Fehlerbehandlung und Performance

Fehlerbehandlung: Seite nicht abstürzen lassen

Fehler beim Datenabruf können die ganze Route killen – aktiv absichern.

Methode 1: try/catch

async function Page() {
  try {
    const data = await fetch('https://api.example.com/data')
    if (!data.ok) throw new Error('Failed to fetch')
    return <div>{data.title}</div>
  } catch (error) {
    return <div>Something went wrong. Please try again.</div>
  }
}

Methode 2: error.js

error.tsx im Routenordner (Client-Komponente):

// app/posts/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Fallstrick: redirect in try/catch

// ❌ redirect-Fehler wird von catch geschluckt
async function Page() {
  try {
    const user = await getUser()
    if (!user) redirect('/login')
  } catch (error) {
    return <div>Error</div>
  }
}

// ✅ redirect außerhalb von try/catch
async function Page() {
  let user
  try {
    user = await getUser()
  } catch (error) {
    return <div>Error</div>
  }

  if (!user) redirect('/login')
}

Typische Fehler und Lösungen

Fehler 1: Relativer fetch-Pfad auf dem Server

// ❌ Server kennt keine Base-URL
async function Page() {
  const data = await fetch('/api/posts')
}

// ✅ Absolute URL
async function Page() {
  const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`)
}

// ✅ Besser: direkt DB
async function Page() {
  const posts = await db.post.findMany()
}

Fehler 2: response.ok nicht prüfen

// ❌ fetch wirft bei 404 nicht automatisch
async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return <div>{data.title}</div>
}

// ✅ Status prüfen
async function Page() {
  const res = await fetch('https://api.example.com/data')

  if (!res.ok) {
    throw new Error(`HTTP error! status: ${res.status}`)
  }

  const data = await res.json()
  return <div>{data.title}</div>
}

Fehler 3: Eigene Route Handler aus Server Component fetchen

// ❌ unnötiger Umweg
async function Page() {
  const res = await fetch('/api/posts')
  const posts = await res.json()
  return <PostList posts={posts} />
}

// ✅ direkt
async function Page() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

Praxis: Blog-Artikelseite

Anforderungen:

  • Artikelinhalt
  • Autor
  • Empfehlungen

Dateistruktur

app/
  posts/
    [slug]/
      page.tsx
      loading.tsx
      error.tsx

Implementierung

// app/posts/[slug]/page.tsx
import { db } from '@/lib/prisma'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'

export default async function PostPage({
  params,
}: {
  params: { slug: string }
}) {
  const [post, author] = await Promise.all([
    db.post.findUnique({
      where: { slug: params.slug },
    }),
    db.user.findFirst(),
  ])

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <AuthorCard author={author} />
      <div>{post.content}</div>

      <Suspense fallback={<div>Loading recommendations...</div>}>
        <RecommendedPosts currentPostId={post.id} />
      </Suspense>
    </article>
  )
}

function AuthorCard({ author }) {
  return (
    <div>
      <img src={author.avatar} alt={author.name} />
      <span>{author.name}</span>
    </div>
  )
}

async function RecommendedPosts({ currentPostId }: { currentPostId: string }) {
  const recommended = await db.post.findMany({
    where: {
      id: { not: currentPostId },
      published: true,
    },
    take: 3,
  })

  return (
    <div>
      <h3>You might also like</h3>
      {recommended.map((post) => (
        <a key={post.id} href={`/posts/${post.slug}`}>
          {post.title}
        </a>
      ))}
    </div>
  )
}

export const revalidate = 3600

export async function generateStaticParams() {
  const posts = await db.post.findMany({
    select: { slug: true },
  })

  return posts.map((post) => ({
    slug: post.slug,
  }))
}
// app/posts/[slug]/loading.tsx
export default function Loading() {
  return (
    <div>
      <div className="skeleton h-12 w-3/4" />
      <div className="skeleton h-4 w-1/4 mt-4" />
      <div className="skeleton h-64 mt-8" />
    </div>
  )
}
// app/posts/[slug]/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Failed to load post</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Entscheidungen im Überblick

  1. Direkte DB – Server Component, kein API-Umweg nötig
  2. Promise.all – Artikel und Autor unabhängig → parallel
  3. Suspense für Empfehlungen – sekundär, blockiert Hauptinhalt nicht
  4. revalidate: 3600 – Inhalt selten geändert, entlastet die DB

Fazit

Kernpunkte:

  1. In Server Components zuerst direkt die Datenbank – außer Client braucht die Daten oder es ist eine Drittanbieter-API.
  2. async/await ist straightforwardPromise.all für Parallelität, Suspense für UX.
  3. Next.js 15 cached fetch standardmäßig nichtforce-cache, no-store oder revalidate bewusst wählen.
  4. Fehler sauber behandelnresponse.ok, error.tsx, redirect nicht in try/catch.

Datenabruf in Server Components ist deutlich einfacher als auf dem Client: weniger Loading-State, Race Conditions und Abbruch-Logik. Für den Umstieg auf den App Router lohnt sich allein dieser Punkt.

Im nächsten Projekt einfach ausprobieren – bei Fragen hilft ein Blick zurück auf diesen Leitfaden.

Vollständiger Workflow: Datenabruf in Next.js Server Components

Von fetch vs. Datenbankabfrage über async-Komponenten und Caching bis zur Fehlerbehandlung

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: fetch vs. Datenbankabfrage wählen

    Direkte Datenbankabfrage (empfohlen):
    • Schneller: weniger Latenz, kein zusätzlicher API-Hop
    • Sicherer: DB-Secrets bleiben auf dem Server
    • Wenn: Datenbank vom Server erreichbar

    Code:
    ```tsx
    import { db } from '@/lib/db'

    export default async function Page() {
    const users = await db.user.findMany()
    return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>
    }
    ```

    fetch nutzen:
    • Drittanbieter-APIs
    • Cross-Origin-Szenarien
    • Hinweis: Next.js 15 cached standardmäßig nicht

    Code:
    ```tsx
    export default async function Page() {
    const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache'
    })
    const data = await res.json()
    return <div>{data}</div>
    }
    ```

    Empfehlung: zuerst direkte DB; fetch nur für externe APIs.
  2. 2

    Step 2: async/await-Komponenten schreiben

    Komponente als async markieren:
    ```tsx
    export default async function Page() {
    const data = await fetchData()
    return <div>{data}</div>
    }
    ```

    Parallel mit Promise.all:
    ```tsx
    export default async function Page() {
    const [users, posts] = await Promise.all([
    fetchUsers(),
    fetchPosts()
    ])
    return <div>...</div>
    }
    ```

    Suspense für Lade-UX:
    ```tsx
    import { Suspense } from 'react'

    export default function Page() {
    return (
    <Suspense fallback={<div>Loading...</div>}>
    <UserList />
    </Suspense>
    )
    }

    async function UserList() {
    const users = await fetchUsers()
    return <div>{users.map(...)}</div>
    }
    ```

    Wichtig: Server Components dürfen async sein; parallel laden; Suspense außerhalb der async-Komponente.
  3. 3

    Step 3: Caching-Strategie konfigurieren

    Next.js 15: explizit konfigurieren

    Kein Cache (Echtzeit):
    ```tsx
    fetch(url, { cache: 'no-store' })
    ```

    Dauerhaft cachen (statisch):
    ```tsx
    fetch(url, { cache: 'force-cache' })
    ```

    ISR (zeitgesteuert):
    ```tsx
    fetch(url, { next: { revalidate: 3600 } })
    ```

    Empfehlung:
    • Echtzeit → no-store
    • Statisch → force-cache
    • Regelmäßige Updates ohne Echtzeit → revalidate

    Philosophie in v15: explizit statt implizit.
  4. 4

    Step 4: Fehlerbehandlung

    response.ok prüfen:
    ```tsx
    const res = await fetch(url)
    if (!res.ok) {
    throw new Error('Failed to fetch')
    }
    const data = await res.json()
    ```

    error.tsx als Fallback:
    ```tsx
    'use client'
    export default function Error({ error, reset }) {
    return (
    <div>
    <h2>Fehler: {error.message}</h2>
    <button onClick={reset}>Erneut versuchen</button>
    </div>
    )
    }
    ```

    redirect:
    ```tsx
    // ❌ redirect in try/catch
    try {
    if (!user) redirect('/login')
    } catch (e) {}

    // ✅ redirect außerhalb
    if (!user) redirect('/login')
    ```

    Wichtig: ok prüfen, error.tsx nutzen, redirect nicht in try/catch.

FAQ

Warum können Server Components direkt await nutzen?
Server Components laufen auf dem Server, nicht im Browser.

Möglich sind u. a.:
• Direkte DB-Verbindung (Prisma, Drizzle, SQL)
• Dateisystem (z. B. Markdown)
• Interne Dienste ohne CORS
• Umgebungsvariablen und Secrets – nicht im Client

Beispiel:
```tsx
import { db } from '@/lib/db'

export default async function Page() {
const users = await db.user.findMany()
return <div>{users.map(...)}</div>
}
```

Vorteile:
• Kein useEffect/useState
• Weniger Race Conditions
• Einfacherer Datenabruf als auf dem Client
Wann fetch, wann direkte Datenbankabfrage?
Direkte DB (empfohlen):
• Schneller (&lt;10 ms Server→DB vs. 100 ms+ zum Client)
• Secrets bleiben serverseitig
• Wenn DB vom Server erreichbar

fetch:
• Drittanbieter-APIs
• Cross-Origin
• Bestehende API-Architektur unverändert lassen

Empfehlung: DB zuerst; fetch nur extern.

```tsx
// ❌ Anti-Pattern: eigene API aus Server Component fetchen
const res = await fetch('/api/users')

// ✅ Direkt
const users = await db.user.findMany()
```
Wie schreibt man async-Komponenten?
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```

Parallel:
```tsx
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
```

Suspense:
```tsx
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
```

Server Components können async sein; Promise.all und Suspense verbessern Performance und UX.
Wie konfiguriert man Caching in Next.js 15?
Standard: kein Cache – explizit setzen:

```tsx
fetch(url, { cache: 'no-store' }) // Echtzeit
fetch(url, { cache: 'force-cache' }) // statisch
fetch(url, { next: { revalidate: 3600 } }) // ISR
```

• Echtzeit → no-store
• Statisch → force-cache
• Regelmäßige Updates → revalidate

Breaking Change beim Upgrade von v14: Caching-Verhalten prüfen.
Wie behandelt man Fehler in Server Components?
response.ok prüfen; bei Fehler throw.

error.tsx in der Route als Client-Komponente.

redirect wirft intern – nicht in try/catch:
```tsx
if (!user) redirect('/login')
try {
// weitere Logik
} catch (e) {
// Fehler-UI
}
```

Kurz: ok prüfen, error.tsx, redirect außerhalb von catch.

10 Min. Lesezeit · Veröffentlicht am: 19. Dez. 2025 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog