Loading...
Loading...
Use loading.tsx files and React Suspense to split data fetching across multiple async components with skeleton loaders. Each page.tsx gets a matching loading.tsx, and async data components are wrapped in Suspense boundaries with skeleton fallbacks that mimic component design using the Skeleton UI.
npx skill4agent add madsnyl/t3-template suspense-and-loadingPromise.all()src/app/
├─ page.tsx (async Server Component)
├─ loading.tsx (instant skeleton layout for entire page)
├─ admin/
│ ├─ page.tsx (async Server Component)
│ ├─ loading.tsx (skeleton for admin page)
│ └─ _components/
│ ├─ user-list.tsx (async component with loading function inside)
│ ├─ dashboard-stats.tsx (async component with loading function inside)
│ └─ user-form.tsx (client component, no loading needed)
└─ users/
├─ page.tsx
├─ loading.tsx
└─ _components/
└─ user-detail.tsxloading.tsx_components/page.tsxasyncpage.tsx<Suspense>page.tsxfallbackasyncloadXxx()fetchXxx()Skeleton<Suspense fallback={<SkeletonXxx />}>error.tsx// src/app/admin/_components/user-list.tsx
"use server";
import { Skeleton } from "~/components/ui/skeleton";
import { getUsers } from "~/services";
// Skeleton loader that mirrors UserList layout
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}
// Async Server Component
export default async function UserList({ page = 1, pageSize = 10 }) {
const { items, totalPages } = await getUsers(page, pageSize);
return (
<div className="space-y-4">
{items.map((user) => (
<div key={user.id} className="flex gap-4 rounded border p-4">
<Avatar src={user.avatar} alt={user.name} />
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
))}
<Pagination total={totalPages} current={page} />
</div>
);
}// src/app/admin/page.tsx
import { Suspense } from "react";
import { UserList, UserListSkeleton } from "./_components/user-list";
import { DashboardStats, DashboardStatsSkeleton } from "./_components/dashboard-stats";
export default async function AdminPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* Suspense boundary for stats */}
<Suspense fallback={<DashboardStatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* Suspense boundary for user list */}
<Suspense fallback={<UserListSkeleton />}>
<UserList page={1} pageSize={10} />
</Suspense>
</div>
);
}// src/app/admin/loading.tsx
import { Skeleton } from "~/components/ui/skeleton";
export default async function AdminLoading() {
return (
<div className="space-y-8 p-6">
{/* Keep static text in loading animation */}
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* Stats skeleton */}
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded border p-4">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* User list skeleton */}
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
</div>
);
}// src/app/dashboard/_components/dashboard-stats.tsx
import { Skeleton } from "~/components/ui/skeleton";
import { getStats, getRecentActivity, getCharts } from "~/services";
async function loadDashboard() {
// Fetch all data in parallel
const [stats, activity, charts] = await Promise.all([
getStats(),
getRecentActivity(),
getCharts(),
]);
return { stats, activity, charts };
}
export async function DashboardStatsSkeleton() {
return (
<div className="space-y-6">
{/* Stats grid skeleton */}
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border p-6">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
{/* Activity skeleton */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
{/* Chart skeleton */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
export async function DashboardStats() {
const { stats, activity, charts } = await loadDashboard();
return (
<div className="space-y-6">
{/* Stats section */}
<div className="grid grid-cols-3 gap-4">
{stats.map((stat) => (
<Card key={stat.id}>
<CardHeader>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-xs text-green-600">+{stat.change}%</p>
</CardContent>
</Card>
))}
</div>
{/* Activity section */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{activity.map((item) => (
<ActivityItem key={item.id} item={item} />
))}
</CardContent>
</Card>
{/* Charts section */}
<Card>
<CardHeader>
<CardTitle>Analytics</CardTitle>
</CardHeader>
<CardContent>
<AnalyticsChart data={charts} />
</CardContent>
</Card>
</div>
);
}// src/app/users/page.tsx
import { Suspense } from "react";
import { UserDetail, UserDetailSkeleton } from "./_components/user-detail";
export default async function UsersPage({ searchParams }) {
const { id } = searchParams;
return (
<div>
<h1>Users</h1>
{/* Only render detail if ID is provided */}
{id && (
<Suspense fallback={<UserDetailSkeleton />}>
<UserDetail userId={id} />
</Suspense>
)}
</div>
);
}error.tsxloading.tsx// src/app/admin/error.tsx
"use client";
import { useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Admin page error:", error);
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="max-w-md space-y-4 text-center">
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
</div>
);
}load*fetch**Skeleton// BAD
export default async function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header /> {/* Blocks on slow query */}
<Sidebar /> {/* Blocks on slow query */}
<SlowDataComponent />
</Suspense>
);
}// GOOD
export default async function Page() {
return (
<>
<Header />
<Sidebar />
<Suspense fallback={<SlowDataSkeleton />}>
<SlowDataComponent />
</Suspense>
</>
);
}// BAD - loading.tsx is calling getUsers!
export default async function AdminLoading() {
const users = await getUsers(); // 🚨 This is async, defeats purpose
return <div>{users.length} users loading...</div>;
}// GOOD - Just a skeleton, no logic
export default async function AdminLoading() {
return (
<div>
<Skeleton className="h-10 w-64 mb-4" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
</div>
);
}// BAD - Skeleton is too small, causes layout shift
export async function UserListSkeleton() {
return <Skeleton className="h-4 w-20" />; // Way too small!
}// GOOD - Matches UserList exactly
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}