Next.js Loading-Zustandsverwaltung: loading.tsx und Suspense – Praxisleitfaden

Kennen Sie das? Ein Nutzer klickt auf einen Link – und drei Sekunden lang bleibt der Bildschirm weiß, ohne jede Rückmeldung. Die Gedanken kreisen: „Ist das abgestürzt?” Dann wird wild F5 gedrückt – und gerade als die Seite fertig geladen ist, wird alles wieder neu geladen …
Früher habe ich Loading genauso gelöst. Bei jeder neuen Seite kam in die Komponente:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
Der Code war lang und repetitiv – und auf jeder Seite von vorn. Schlimmer noch: Im Team war jede Loading-Logik anders – mal globaler State, mal Context. Wartung wurde zum Albtraum.
Bis ich in der Next.js-Dokumentation entdeckte: Next.js bringt längst eine elegantere Lösung mit – loading.tsx und Suspense.
Seitdem ist Loading-Verwaltung erstaunlich einfach: weniger Code, besseres Nutzererlebnis. In diesem Artikel teile ich die Praxiserfahrung damit.
Warum loading.tsx und Suspense?
Schwächen klassischer Ansätze
Ein konkretes Beispiel: eine Blog-Listen-Seite, klassisch geschrieben:
// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
Sieht akzeptabel aus – aber:
- Code-Redundanz: Dieselbe State-Logik auf jeder Seite
- Zersplitterter State: loading, data, error in drei States – leicht desynchron
- Nur Client Component: useState und useEffect erzwingen Client-Rendering – SSR-Vorteile gehen verloren
- Schlechtes UX: Zwischen Klick und sichtbarem Loading oft spürbarer weißer Bildschirm
Beim Code Review sieht man alles Mögliche: Loading in Context, Zustand global, oder pro Komponente ad hoc. In großen Projekten wird das unhaltbar.
Die Next.js-Lösung
Der App Router bietet drei Kernmechanismen:
1. loading.tsx – Convention over Configuration
Eine loading.tsx im Routenordner – Next.js nutzt sie automatisch als Loading-UI. Kein useState, kein manuelles Suspense-Wrapping.
2. Suspense – nativ in React 18
Suspense steuert Loading auf Komponentenebene. Langsame Teile bekommen eine Grenze – der Rest rendert weiter, ohne die ganze Seite zu blockieren.
3. Streaming – schrittweise Anzeige
Mit Streaming erscheint die Seite in Teilen: zuerst Header, dann Sidebar, zuletzt langsame Daten. Kein starres Warten auf Weiß.
Mit Skeleton Screens und Streaming sinken FCP (First Contentful Paint) und LCP (Largest Contentful Paint) spürbar – PageSpeed Insights verbessert sich oft um mehrere Punkte.
loading.tsx – Grundlagen
Schnellstart: die erste loading.tsx
Direkt zur Sache – die einfachste loading.tsx.
Verzeichnisstruktur:
app/
blog/
page.tsx
Einfach loading.tsx in blog/ anlegen:
app/
blog/
loading.tsx ← neu
page.tsx
Inhalt:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="ml-4">Wird geladen …</p>
</div>
);
}
Zehn Zeilen – fertig. Beim Aufruf von /blog zeigt Next.js diese Komponente, bis page.tsx bereit ist.
Wichtig: Sie müssen Suspense nicht manuell wrappen. Next.js packt page.tsx intern in <Suspense fallback={<Loading />}>.
Beim ersten Mal dachte ich: „Zu einfach, funktioniert das?” – ja, und der Code wird deutlich schlanker.
Geltungsbereich von loading.tsx
Entscheidend ist der Begriff Route Segment: loading.tsx gilt für page.tsx im selben Ordner und alle Unterrouten.
app/
blog/
loading.tsx ← gilt für /blog und /blog/[id]
page.tsx ← /blog Listen-Seite
[id]/
page.tsx ← /blog/123 Detail-Seite
Angezeigt wird sie bei:
- Navigation zu
/blog - Wechsel von der Liste zu
/blog/123
Layouts bleiben unberührt. Steht in blog/layout.tsx eine Navigation, bleibt sie sichtbar – nur der page.tsx-Inhalt wird durch Loading ersetzt.
Das ist „Shared Layouts bleiben interaktiv”: Nutzer können während des Ladens woanders navigieren, statt die ganze UI einzufrieren.
Layout (dauerhaft sichtbar)
├─ Navigation
└─ Suspense Boundary
├─ Loading UI (während Daten laden)
└─ Page (nach dem Laden)
Server Component vs. Client Component
loading.tsx ist standardmäßig eine Server Component – meist reicht JSX.
Für Animationen (Framer Motion) oder clientseitige Bibliotheken: 'use client' setzen:
// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center min-h-screen"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</motion.div>
);
}
Meine Regel: Server Component, solange möglich – weniger Client-Bundle, schnellerer First Load.
Skeleton Screens in der Praxis
Warum Skeleton besser ist als ein Spinner
Ein drehender Spinner ist vertraut – aus UX-Sicht sind Skeleton Screens oft überlegen.
Psychologisch: Bei Skeletons erwartet das Gehirn „Inhalt kommt gleich” – die wahrgenommene Wartezeit sinkt. Beim Spinner weiß man nur „es lädt”, nicht was oder wie lange – mehr Unruhe.
Skeletons zeigen außerdem die grobe Layout-Struktur: drei Balken suggerieren drei Artikel – Erwartungen sind gesetzt.
Drei Implementierungswege
Variante 1: Reines CSS (am leichtesten)
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8 animate-pulse">
{/* Titel-Skeleton */}
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
{/* Auszug-Skeleton */}
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
{/* Meta-Skeleton */}
<div className="flex gap-4 mt-4">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
);
}
Null Dependencies, beste Performance – etwas mehr Handarbeit beim Styling.
Variante 2: react-loading-skeleton (am schnellsten)
npm install react-loading-skeleton
// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton height={32} width="75%" className="mb-4" />
<Skeleton count={2} />
<div className="flex gap-4 mt-4">
<Skeleton width={80} />
<Skeleton width={100} />
</div>
</div>
))}
</div>
);
}
Praktisch, gute Animation – in kleinen Projekten mein Favorit.
Variante 3: shadcn/ui (am professionellsten)
npx shadcn-ui@latest add skeleton
// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton className="h-8 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}
Passt zum Design System – kein Extra-Styling nötig.
Design-Prinzipien für Skeletons
- Layout treffen: Struktur wie der echte Inhalt (Titel, Auszug, Tags → entsprechende Skeleton-Bereiche)
- Dezente Animation: Sanftes Pulsieren reicht – zu auffällig lenkt ab
- Angemessene Anzahl: 3–5 Einträge reichen meist, nicht den ganzen Viewport füllen
Vollständiges Beispiel: Blog-Listen-Seite
loading.tsx:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<article key={i} className="border-b pb-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-11/12"></div>
<div className="h-4 bg-gray-200 rounded w-4/5"></div>
</div>
<div className="flex gap-3">
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</article>
))}
</div>
</div>
);
}
page.tsx als Server Component:
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // Bei jedem Aufruf neu laden
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog-Artikel</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-3">
<a href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex gap-3">
{post.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</div>
);
}
page.tsx als async-Funktion mit await – kein useState, kein useEffect. Server Component: kein Extra-Client-Bundle, schnellerer First Paint.
Debugging: React DevTools
Loading blitzt oft zu kurz auf. Tipp mit React DevTools:
- React DevTools installieren
- Tab „Components” öffnen
<Suspense>finden- Rechtsklick → „Suspend this Suspense boundary”
Loading bleibt sichtbar – Styling in Ruhe anpassen, dann Suspend aufheben.
Hätte mir früher viel Zeit gespart.
Suspense – Fortgeschritten
Suspense-Grenzen manuell setzen
loading.tsx reicht nicht immer. Mehrere unabhängige Datenquellen sollen getrennt laden – dann manuelle Suspense-Grenzen.
Typischer Fehler – Suspense zu tief:
// ❌ Falsch – Suspense zu tief
async function PostList() {
const posts = await fetchPosts();
return (
<Suspense fallback={<Loading />}> {/* funktioniert so nicht! */}
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
</Suspense>
);
}
Suspense muss höher im Baum stehen, um asynchrone Operationen darunter abzufangen.
Richtig – Suspense im Eltern-Component:
// ✅ Richtig – Suspense im Eltern-Component
export default function BlogPage() {
return (
<div>
<h1>Blog-Artikel</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
);
}
Suspense als Schleuse: Solange unten etwas wartet, fallback – dann echter Inhalt.
Dynamische Routen: Sonderfall
Produktdetail /products/[id], Wechsel von id=1 zu id=2 – loading.tsx erscheint oft nicht.
Inhalt springt direkt von A nach B – kein Loading-Übergang.
React optimiert: Gleicher Komponententyp → Instanz wird wiederverwendet, nur Props ändern sich. Suspense suspendiert nicht neu.
Lösung: key am Suspense, damit React eine neue Instanz erzeugt:
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<Suspense key={params.id} fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
);
}
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
<Suspense key={params.id} ...> – bei ID-Wechsel neue Instanz, Loading erscheint wieder.
Halber Tag Debugging – bis ein GitHub-Issue den key-Trick erwähnte. Manchmal ist die Lösung trivial, wenn man sie kennt.
Mehrere Ladezustände koordinieren
Dashboard mit Nutzerinfo, Statistik, Aktivität – zwei Strategien:
Strategie 1: Alles in einem Suspense
export default function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserInfo /> {/* API 1 */}
<Statistics /> {/* API 2 */}
<RecentActivity /> {/* API 3 */}
</Suspense>
);
}
Vorteil: einfach, vollständiger Inhalt auf einmal
Nachteil: langsamste API bestimmt die Wartezeit
Strategie 2: Mehrere Suspense-Grenzen
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
Vorteil: Schnelles zuerst – kürzere gefühlte Wartezeit
Nachteil: Layout kann „springen”
Meine Wahl nach Wichtigkeit:
- Kern-Daten (Nutzerinfo) gemeinsam in einem Suspense
- Sekundäres (Empfehlungen, Ads) in eigenen Suspense-Grenzen
Häufige Probleme und Lösungen
Suspense wirkt nicht
Checkliste:
1. Datenabruf-Methode
Suspense braucht kompatible Patterns im App Router:
- ✅ await direkt in Server Components (empfohlen)
- ✅ Suspense-fähige Libraries (SWR, React Query)
- ❌ fetch in useEffect
- ❌ klassisches Promise.then ohne Integration
2. Position im Baum
Suspense über der datenladenden Komponente – nicht darin oder darunter.
3. Versionen
- React 18+
- Next.js 13+ (App Router)
4. Debuggen
React DevTools: Suspense manuell suspendieren. Keine Reaktion → Suspense greift nicht – obige Punkte prüfen.
Fallstrick: useFormStatus
Mit Server Actions und useFormStatus für Submit-Status:
useFormStatus funktioniert nur in Client Components.
Das Formular sollte in einer Server Component gerendert werden, damit die Server Action gebunden ist.
Pattern: Server Component rendert form, Client Component den Button-Status
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// Formular verarbeiten …
await saveToDatabase(formData);
}
// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}
// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Wird gesendet …' : 'Absenden'}
</button>
);
}
Form in Server Component, Button in Client Component – Server Action und Pending-State funktionieren zusammen.
Prefetch und Loading
Next.js <Link> prefetcht standardmäßig (wenn der Link im Viewport ist).
Dann blitzt Loading kurz oder gar nicht – Daten sind schon da.
Zum Testen Prefetch abschalten:
<Link href="/blog" prefetch={false}>
Blog
</Link>
In Produktion Prefetch eher an lassen. Bei zu kurzem Loading: Mindestanzeige (z. B. 300 ms) oder Skeleton statt Spinner.
Zusammenfassung
Kernpunkte:
-
loading.tsx ist Best Practice auf Routenebene – Datei im Routenordner, Next.js erledigt den Rest. Kein manuelles useState, deutlich weniger Code.
-
Skeleton schlägt Spinner – Layout vorab, weniger Unruhe. CSS, react-loading-skeleton oder UI-Bibliothek – je nach Projekt.
-
Suspense oben im Baum – Schleuse für asynchrone Kinder. Falsche Position → kein Effekt.
-
Dynamische Routen:
keynicht vergessen – sonst kein Loading bei ID-Wechsel.<Suspense key={params.id}>. -
Mehrere Datenquellen: Suspense gezielt splitten – Kern zusammen, Rest asynchron.
Vom manuellen useState zu loading.tsx ist das kein Mehraufwand, sondern smarter arbeiten. Weniger Code, weniger Bugs, besseres UX.
Nächste Schritte
Sofort: Ein bestehendes Projekt, eine einfache Listen-Seite – Loading auf loading.tsx umstellen. Einmal selbst bauen hilft mehr als zehn Artikel.
Vertiefung: Als Nächstes Error Boundaries – Loading und Errors sind ein Paar (error.tsx). Dazu folgt ein eigener Praxisartikel.
Austausch: Wie lösen Sie Loading in Ihren Projekten? Welche Fallstricke? Gerne in den Kommentaren.
Referenzen:
- Next.js-Dokumentation – loading.js
- Next.js-Dokumentation – Loading UI und Streaming
- React-Dokumentation – Suspense
Next.js Loading-Zustandsverwaltung – vollständiger Ablauf
loading.tsx und Suspense für professionelles Ladeerlebnis – ohne manuelles useState
⏱️ Estimated time: 1 hr
- 1
Step 1: loading.tsx anlegen
loading.tsx im Routenverzeichnis erstellen:
• app/dashboard/loading.tsx – Loading für die Dashboard-Route
• app/products/[id]/loading.tsx – Loading für dynamische Routen
Dateiinhalt:
export default function Loading() {
return <div>Wird geladen …</div>
}
Next.js zeigt diese Komponente beim Laden automatisch an - 2
Step 2: Skeleton Screen implementieren
Professionellere Loading-UI:
• Skeleton-Komponente im Layout des echten Inhalts
• Struktur an den finalen Inhalt anpassen
• Animation für besseres Erlebnis
Beispiel:
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
)
} - 3
Step 3: Asynchrone Komponenten mit Suspense wrappen
Suspense in Komponenten nutzen:
• Komponenten mit asynchronem Datenabruf wrappen
• fallback für Loading setzen
• Verschachtelte Suspense für feingranulares Laden
Beispiel:
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
Mehrere Komponenten jeweils eigenes Suspense:
• Unabhängiges Laden
• Schnelles zuerst, langsames später
• Besseres Nutzererlebnis - 4
Step 4: Loading bei dynamischen Routen
Loading für dynamische Routen:
• loading.tsx im dynamischen Routenordner
• Next.js behandelt Parameterwechsel automatisch
• Kein manuelles Loading-State-Management
Beispiel:
app/products/[id]/
├── loading.tsx # bei Parameterwechsel automatisch
└── page.tsx
Navigation von /products/1 zu /products/2
zeigt loading.tsx automatisch - 5
Step 5: Ladeerlebnis optimieren
Optimierungstipps:
• Skeleton statt einfachem Spinner
• Loading-UI an echtes Layout anpassen
• Animation (animate-pulse) nutzen
• Suspense für Streaming sinnvoll einsetzen
Vermeiden:
• loading.tsx überall blind einsetzen
• zu komplexe Loading-UI
• Fehlerbehandlung vergessen (error.tsx ergänzen) - 6
Step 6: Testen und validieren
Test-Schwerpunkte:
• Loading bei Navigation
• Loading bei dynamischen Parameterwechseln
• Verhalten bei langsamer Verbindung
• Flüssigkeit der Loading-UI
Checkliste:
• Passende Loading-States für alle Routen
• Loading-UI entspricht dem Inhaltslayout
• Kein Flackern oder Layout-Shift
• Flüssiges Nutzererlebnis
FAQ
Was ist der Unterschied zwischen loading.tsx und manuellem useState?
Wann wird loading.tsx angezeigt?
Was ist der Unterschied zwischen Suspense und loading.tsx?
Wie implementiert man Skeleton Screens?
Wie behandelt man Loading bei dynamischen Routen?
Kann man das Loading-Styling anpassen?
Beeinträchtigt loading.tsx die Performance?
11 Min. Lesezeit · Veröffentlicht am: 5. Jan. 2026 · 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 Engineering-Setup: ESLint + Prettier + Husky in einem Rutsch
Wurde Ihr PR am Freitagabend wegen Formatproblemen abgelehnt? Verursacht ein uneinheitlicher Code-Stil sinnlose Merge-Konflikte? Schritt für Schritt konfigurieren Sie ESLint, Prettier und Husky für automatische Checks und Formatierung – effizientere Teamarbeit inklusive.
Teil 31 von 51
Nächster
Next.js 404- und 500-Seiten anpassen: Vom technischen Setup bis zum Design
Schritt-für-Schritt-Anleitung für benutzerdefinierte Next.js-Fehlerseiten – mit vollständigen Codebeispielen für not-found.tsx, error.tsx und global-error.tsx, Design-Best-Practices und Lösungen für häufige Probleme zur besseren UX und geringerer Absprungrate
Teil 33 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