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

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:
- Komponente als
async functiondeklarieren - Datenbankabfrage direkt mit
await - Keine React-Hooks (
useState,useEffectusw.) - 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:
- Ist es eine Server Component? → Ja: weiter; nein (Client Component): zu Frage 3
- Woher kommen die Daten?
- Eigene Datenbank → direkt abfragen
- Drittanbieter-API → fetch
- 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
- Direkte DB – Server Component, kein API-Umweg nötig
- Promise.all – Artikel und Autor unabhängig → parallel
- Suspense für Empfehlungen – sekundär, blockiert Hauptinhalt nicht
- revalidate: 3600 – Inhalt selten geändert, entlastet die DB
Fazit
Kernpunkte:
- In Server Components zuerst direkt die Datenbank – außer Client braucht die Daten oder es ist eine Drittanbieter-API.
- async/await ist straightforward –
Promise.allfür Parallelität, Suspense für UX. - Next.js 15 cached fetch standardmäßig nicht –
force-cache,no-storeoderrevalidatebewusst wählen. - Fehler sauber behandeln –
response.ok,error.tsx,redirectnicht 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
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
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
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
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?
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?
• Schneller (<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?
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?
```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?
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
Next.js Komplettleitfaden
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Next.js App Router: Häufige Fallstricke und Lösungen – 8 praxiserprobte Tipps
Vom Datenabruf bis zur Fehlerbehandlung: 14 typische Next.js App Router-Fallstricke und ihre Lösungen. Server Components, Client Components, Caching, Migration – Praxiswissen, mit dem Sie 80 % der häufigen Fehler vermeiden.
Teil 7 von 51
Nächster
Next.js SSR vs SSG vs ISR: Leitfaden zur Wahl der Rendering-Strategie
Unsicher, wann Sie in Next.js SSR, SSG oder ISR einsetzen? Dieser Artikel vergleicht Praxis-Szenarien und liefert einen Entscheidungsbaum – plus Lösungen für ISR-Konfiguration, die nicht greift, und langsame First-Paint-Ladezeiten.
Teil 9 von 51
Ähnliche Beiträge
Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick

Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick
Next.js 15 in der Praxis: Wie ich an einem Wochenende ein produktionsreifes Blog-System aufgebaut habe


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen