Step 1: Project Structure (in an Existing App)
Assume your app is already set up with the app/ directory. Add these files for a table page at /users:
app/ users/ page.tsx // Your main table page (fetches and renders data) loading.tsx // Separate skeleton file (called automatically by Next.js)
Step 2: Skeleton Component (Separate Loading Page)
Create app/users/loading.tsx. This exports a default component that renders a skeleton mimicking your table structure. Next.js automatically wraps your page.tsx in Suspense and shows this during loading.
// app/users/loading.tsx
import { Skeleton } from "@/components/ui/skeleton"; // Optional: Use shadcn/ui Skeleton if installed
export default function Loading() {
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-48" /> {/* Header title skeleton */}
<Skeleton className="h-10 w-32" /> {/* Button skeleton */}
</div>
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-4 font-medium">ID</th>
<th className="text-left p-4 font-medium">Name</th>
<th className="text-left p-4 font-medium">Email</th>
<th className="text-left p-4 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => ( // 5 placeholder rows
<tr key={i} className="border-b hover:bg-muted/50">
<td className="p-4"><Skeleton className="h-4 w-12" /></td>
<td className="p-4"><Skeleton className="h-4 w-64" /></td>
<td className="p-4"><Skeleton className="h-4 w-80" /></td>
<td className="p-4"><Skeleton className="h-8 w-20" /></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-4">
<Skeleton className="h-10 w-24" /> {/* Pagination skeleton */}
</div>
</div>
);
}
- Why separate? Next.js detects loading.tsx and uses it as the Suspense fallback only for this route (/users). No manual wrapping needed.
- Customization: Adjust row count, widths, and heights to match your real table. Add shimmer animation with Tailwind: animate-pulse on <tr> or <td>.
- If no shadcn/ui: Replace Skeleton with a div like <div className=”h-4 w-full bg-muted rounded animate-pulse” />.
Step 3: Main Table Page (Integrate into Existing App)
Update app/users/page.tsx. This fetches data (simulate with fetch for async loading). The skeleton shows automatically while data loads.
// app/users/page.tsx
import { Suspense } from "react"; // Already handled by Next.js, but explicit for clarity
interface User {
id: number;
name: string;
email: string;
}
async function fetchUsers(): Promise<User[]> {
// Simulate API delay; replace with your real endpoint
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error("Failed to fetch users");
return res.json();
}
export default async function UsersPage() {
const users = await fetchUsers(); // This triggers the loading state
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Users</h1>
<button className="px-4 py-2 bg-blue-500 text-white rounded">Add User</button>
</div>
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left p-4 font-medium">ID</th>
<th className="text-left p-4 font-medium">Name</th>
<th className="text-left p-4 font-medium">Email</th>
<th className="text-left p-4 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b hover:bg-muted/50">
<td className="p-4">{user.id}</td>
<td className="p-4">{user.name}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">
<button className="px-2 py-1 bg-green-500 text-white rounded text-sm">Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-4">
<button className="px-4 py-2 bg-gray-500 text-white rounded">Next Page</button>
</div>
</div>
);
}
How it works: When navigating to /users, Next.js shows loading.tsx immediately (server-rendered for speed). Once fetchUsers() resolves, it streams in the real table, swapping seamlessly.
In existing app: Drop this into any route folder (e.g., app/dashboard/). If your table is client-side (e.g., uses useState/useEffect), wrap the table in <Suspense fallback={<YourSkeleton />}> instead.
Error handling: Add app/users/error.tsx for failures (similar convention).