Integrated Project Dashboard and PPR: TaskFlow Static Shell + Dynamic Data Flow
The dashboard is the "cockpit" of a SaaS platform—a static framework lets you see the layout at a glance, while dynamic data streams populate key metrics in real time.
1. What You'll Learn
- Build a global navigation layout (Sidebar + TopNav + Breadcrumb)
- Dashboard PPR Static Shell Mode (
<Suspense>Dynamic Widget) Promise.allParallel data retrieval from multiple independent data sourceserror.tsxGlobal Error Boundaries +loading.tsxSkeleton Screen DesigngenerateStaticParamsPre-generate static project detail pages- Aggregate Queries on Activity Stream Data
2. A True Story of a Front-End Architect
(1) Pain Point: The dashboard takes 8 seconds to load, and all the users have left.
Alice’s TaskFlow received 50 user feedback comments during beta testing—92% of them complained about the Dashboard’s loading speed. The problem lay in the sequential data retrieval: first retrieving the number of projects → then the number of tasks → then the number of members → and finally the activity feed. These four API requests caused a cascading block, taking a total of 8.2 seconds. Charlie (Marketing) said, “I waited 10 seconds and just closed the page.”
(2) Solution for PPR Static Shell + Parallel Data Collection
Use PPR's Static Shell to render the page skeleton instantly, then use
Promise.allto load 4 data sources in parallel.
// PPR Static shell + Parallel Data Retrieval
export default function DashboardPage() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<ActivityFeedWidget />
</Suspense>
</div>
)
}
(3) Revenue
| Dimension | Serial Loading | PPR + Parallel Loading |
|---|---|---|
| First-Screen Rendering Time | 8.2 seconds | 0.3 seconds (static shell) |
| Data loaded | 8.2 seconds | 2.1 seconds (parallel) |
| User Experience | Blank Screen → Redirect | Skeleton → Progressive Loading |
| LCP | 7.8 seconds | 1.2 seconds |
| User Retention | 8% Bounce Rate | 2% Bounce Rate |
3. Global Navigation Layout
(1) Layout Architecture Diagram
graph TB
subgraph "Root Layout"
A[RootLayout<br/>html/body/fonts]
end
subgraph "Dashboard Layout"
B[Sidebar<br/>Organization<br/>Navigation<br/>User Menu]
C[TopNav<br/>Search Bar<br/>Notifications<br/>Breadcrumb]
D[Main Content<br/>{children}]
end
A --> B
B --> C
C --> D
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#ffeeba
style D fill:#f8d7da
(2) Dashboard Layout Components
// src/app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/layout/sidebar"
import { TopNav } from "@/components/layout/top-nav"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar user={session.user} />
<div className="flex flex-1 flex-col overflow-hidden">
<TopNav user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}
▶ Example: Sidebar Component
// src/components/layout/sidebar.tsx
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { prisma } from "@/lib/db"
interface SidebarProps {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
export async function Sidebar({ user }: SidebarProps) {
const org = await prisma.user.findUnique({
where: { id: user.id },
include: {
organization: {
include: {
_count: { select: { projects: true, users: true } },
},
},
},
})
const organization = org?.organization
const initials = user.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<aside className="flex w-64 flex-col border-r bg-card">
<div className="flex h-14 items-center gap-2 border-b px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
TF
</div>
<span className="text-lg font-semibold">TaskFlow</span>
</div>
<div className="flex-1 space-y-1 p-4">
<div className="mb-4 rounded-lg bg-muted p-3">
<p className="text-sm font-medium">{organization?.name}</p>
<p className="text-xs text-muted-foreground">
{organization?._count.projects} projects · {organization?._count.users} members
</p>
</div>
<NavItem href="/dashboard" icon="LayoutDashboard" label="Dashboard" />
<NavItem href="/dashboard/projects" icon="FolderKanban" label="Projects" />
<NavItem href="/dashboard/tasks" icon="CheckSquare" label="Tasks" />
<NavItem href="/dashboard/team" icon="Users" label="Team" />
<NavItem href="/dashboard/settings" icon="Settings" label="Settings" />
<Separator className="my-4" />
<p className="mb-2 px-3 text-xs font-medium text-muted-foreground">
INVITE CODE
</p>
<div className="rounded-md border bg-background p-2 text-center font-mono text-sm">
{organization?.inviteCode}
</div>
</div>
<div className="border-t p-4">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback>{initials || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
</div>
</div>
</aside>
)
}
function NavItem({
href,
icon,
label,
}: {
href: string
icon: string
label: string
}) {
const iconMap: Record<string, string> = {
LayoutDashboard: "\u2302",
FolderKanban: "\u25A1",
CheckSquare: "\u2713",
Users: "\u263A",
Settings: "\u2699",
}
return (
<Link
href={href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="text-lg">{iconMap[icon] || "\u2022"}</span>
{label}
</Link>
)
}
▶ Example: Top Navigation Bar
// src/components/layout/top-nav.tsx
import { Breadcrumb } from "@/components/layout/breadcrumb"
export function TopNav() {
return (
<header className="flex h-14 items-center gap-4 border-b bg-background px-6">
<Breadcrumb />
<div className="ml-auto flex items-center gap-4">
<button
type="button"
className="relative rounded-full p-1 text-muted-foreground hover:text-foreground"
>
<span className="text-lg">\uD83D\uDD14</span>
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
3
</span>
</button>
<button
type="button"
className="rounded-full p-1 text-muted-foreground hover:text-foreground"
>
<span className="text-lg">\u2753</span>
</button>
</div>
</header>
)
}
▶ Example: Breadcrumb Navigation
// src/components/layout/breadcrumb.tsx
"use client"
import { usePathname } from "next/navigation"
import Link from "next/link"
const routeLabels: Record<string, string> = {
dashboard: "Dashboard",
projects: "Projects",
tasks: "Tasks",
team: "Team",
settings: "Settings",
}
export function Breadcrumb() {
const pathname = usePathname()
const segments = pathname.split("/").filter(Boolean)
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground">
Home
</Link>
{segments.slice(1).map((segment, index) => {
const href = "/" + segments.slice(0, index + 2).join("/")
const label = routeLabels[segment] || segment.replace(/-/g, " ")
const isLast = index === segments.length - 2
return (
<span key={segment} className="flex items-center gap-2">
<span className="text-muted-foreground">/</span>
{isLast ? (
<span className="font-medium text-foreground">{label}</span>
) : (
<Link href={href} className="text-muted-foreground hover:text-foreground">
{label}
</Link>
)}
</span>
)
})}
</nav>
)
}
4. Dashboard PPR Static Case
(1) How PPR Works
sequenceDiagram
participant B as Browser
participant N as Next.js Server
participant DB as Database
B->>N: GET /dashboard
N->>B: Static Shell (HTML + CSS)<br/>Skeleton cards visible
B->>N: Streaming starts
N->>DB: Promise.all<br/>(4 parallel queries)
DB-->>N: Results
N->>B: Dynamic content fills skeletons
Note over B: Progressive enhancement
(2) Enable PPR
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
▶ Example: PPR Implementation on the Dashboard Page
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"
export default function DashboardPage() {
return (
<div className="space-y-6">
<Suspense fallback={<div className="h-24 rounded-lg bg-muted animate-pulse" />}>
<WelcomeBanner />
</Suspense>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCompletionWidget />
</Suspense>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
<ActivityFeedWidget />
</Suspense>
</div>
<div>
<Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
<RecentProjectsWidget />
</Suspense>
</div>
</div>
</div>
)
}
(3) Data Retrieval Widget Implementation
// src/components/dashboard/project-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export async function ProjectCountWidget() {
const session = await auth()
const count = await prisma.project.count({
where: {
organization: {
users: { some: { id: session?.user?.id } },
},
status: "ACTIVE",
},
})
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Projects</CardTitle>
<span className="text-2xl">\uD83D\uDCC1</span>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{count}</div>
<p className="text-xs text-muted-foreground">Across your organization</p>
</CardContent>
</Card>
)
}
// src/components/dashboard/task-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export async function TaskCountWidget() {
const session = await auth()
const counts = await prisma.task.groupBy({
by: ["status"],
where: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
_count: true,
})
const total = counts.reduce((acc, c) => acc + c._count, 0)
const todo = counts.find((c) => c.status === "TODO")?._count || 0
const inProgress = counts.find((c) => c.status === "IN_PROGRESS")?._count || 0
const done = counts.find((c) => c.status === "DONE")?._count || 0
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
<span className="text-2xl">\u2705</span>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{total}</div>
<div className="mt-2 flex gap-2 text-xs text-muted-foreground">
<span className="text-yellow-500">{todo} todo</span>
<span className="text-blue-500">{inProgress} in progress</span>
<span className="text-green-500">{done} done</span>
</div>
</CardContent>
</Card>
)
}
5. Parallel Data Retrieval
(1) Comparison of Serial vs. Parallel Processing
graph LR
subgraph "Serial (8.2s)"
A1[Fetch A] --> A2[Fetch B] --> A3[Fetch C] --> A4[Fetch D]
end
subgraph "Parallel (2.1s)"
B1[Fetch A]
B2[Fetch B]
B3[Fetch C]
B4[Fetch D]
B1 --> C[Promise.all]
B2 --> C
B3 --> C
B4 --> C
end
style A4 fill:#f8d7da
style C fill:#d4edda
▶ Example: Using Promise.all to Retrieve Activity Streams in Parallel
// src/components/dashboard/activity-feed-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export async function ActivityFeedWidget() {
const session = await auth()
const [recentTasks, recentComments] = await Promise.all([
prisma.task.findMany({
where: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
orderBy: { createdAt: "desc" },
take: 10,
include: {
creator: { select: { name: true, image: true } },
project: { select: { name: true } },
},
}),
prisma.comment.findMany({
where: {
task: {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
},
},
orderBy: { createdAt: "desc" },
take: 10,
include: {
author: { select: { name: true, image: true } },
task: { select: { title: true } },
},
}),
])
const activities = [
...recentTasks.map((t) => ({
id: t.id,
type: "task_created" as const,
user: t.creator,
description: `created task "${t.title}"`,
project: t.project.name,
time: t.createdAt,
})),
...recentComments.map((c) => ({
id: c.id,
type: "comment_added" as const,
user: c.author,
description: `commented on "${c.task.title}"`,
project: "",
time: c.createdAt,
})),
]
.sort((a, b) => b.time.getTime() - a.time.getTime())
.slice(0, 10)
return (
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={activity.user.image ?? undefined} />
<AvatarFallback>
{activity.user.name?.[0] || "U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<p className="text-sm">
<span className="font-medium">{activity.user.name}</span>
{" "}{activity.description}
</p>
<p className="text-xs text-muted-foreground">
{activity.project && `${activity.project} · `}
{activity.time.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))}
{activities.length === 0 && (
<p className="text-sm text-muted-foreground">
No recent activity
</p>
)}
</div>
</CardContent>
</Card>
)
}
(2) Comparison of Data Acquisition Strategies
| Strategy | Total Time | Implementation Complexity | Suitable Scenarios |
|---|---|---|---|
| Serial await | Sum(N) | Simple | Data dependencies |
| Promise.all | Max(N) | Medium | Independent data sources |
| Promise.allSettled | Max(N) | High | Partial failures are tolerated |
| Streaming + PPR | TTFB ~0 | High | First-Screen Priority |
6. Error Boundaries and Loading Skeletons
(1) Error Boundary Level
graph TB
A[RootLayout] --> B[error.tsx<br/>Global Error]
A --> C[DashboardLayout]
C --> D[error.tsx<br/>Dashboard Error]
D --> E[Dashboard Page]
E --> F[Widget 1<br/>Suspense fallback]
E --> G[Widget 2<br/>Suspense fallback]
E --> H[Widget 3<br/>Error Boundary]
style B fill:#f8d7da
style D fill:#f8d7da
style F fill:#d4edda
style G fill:#d4edda
▶ Example: Global Error Boundary
// src/app/(dashboard)/error.tsx
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex min-h-[400px] items-center justify-center p-6">
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Something went wrong</CardTitle>
<CardDescription>
An unexpected error occurred while loading the dashboard.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error.message || "Unknown error"}
</div>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
<CardFooter>
<Button onClick={() => reset()} variant="outline" className="w-full">
Try again
</Button>
</CardFooter>
</Card>
</div>
)
}
▶ Example: Skeleton Screen Component
// src/components/dashboard/stat-card-skeleton.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export function StatCardSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-6 w-6 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="mb-2 h-9 w-16 animate-pulse rounded bg-muted" />
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
)
}
// src/app/(dashboard)/dashboard/loading.tsx
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
export default function DashboardLoading() {
return (
<div className="space-y-6">
<div className="h-24 animate-pulse rounded-lg bg-muted" />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="h-80 animate-pulse rounded-lg bg-muted" />
</div>
<div>
<div className="h-80 animate-pulse rounded-lg bg-muted" />
</div>
</div>
</div>
)
}
7. Static Project Details Page
(1) Static Page Generation Strategy
| Strategy | Generation Timing | Data Freshness | Build Time | Applicable Scenarios |
|---|---|---|---|---|
| SSG (generateStaticParams) | Build time | Build-time snapshot | Slow (N pages) | Content rarely changes |
| ISR (revalidate) | At build time + on demand | Lagging TTL | Slow (N pages) | Periodic updates |
| SSR (dynamic) | On request | Real-time | Fast (no N required) | Real-time data |
| PPR | Build-time + Streaming | Real-time dynamic portion | Fast (static shell) | Mixed scenarios |
▶ Example: Static Generation of the Project Details Page
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { notFound } from "next/navigation"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ProjectTasks } from "@/components/projects/project-tasks"
import { ProjectMembers } from "@/components/projects/project-members"
export async function generateStaticParams() {
const projects = await prisma.project.findMany({
select: { id: true },
where: { status: "ACTIVE" },
})
return projects.map((project) => ({
id: project.id,
}))
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const session = await auth()
const project = await prisma.project.findUnique({
where: { id },
include: {
organization: true,
creator: { select: { name: true, image: true } },
_count: { select: { tasks: true } },
},
})
if (!project) {
notFound()
}
const statusColors: Record<string, string> = {
ACTIVE: "bg-green-500",
ARCHIVED: "bg-gray-500",
COMPLETED: "bg-blue-500",
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold">{project.name}</h1>
<Badge
variant="secondary"
className={statusColors[project.status]}
>
{project.status}
</Badge>
</div>
{project.description && (
<p className="mt-1 text-muted-foreground">
{project.description}
</p>
)}
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
<span>Created by {project.creator.name}</span>
<span>{project._count.tasks} tasks</span>
<span>{project.organization.name}</span>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ProjectTasks projectId={project.id} />
</div>
<div>
<ProjectMembers projectId={project.id} />
</div>
</div>
</div>
)
}
// src/components/projects/project-tasks.tsx
import { prisma } from "@/lib/db"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface ProjectTasksProps {
projectId: string
}
const statusBadge: Record<string, string> = {
TODO: "bg-yellow-100 text-yellow-800",
IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
}
export async function ProjectTasks({ projectId }: ProjectTasksProps) {
const tasks = await prisma.task.findMany({
where: { projectId },
orderBy: { order: "asc" },
include: {
assignee: { select: { name: true, image: true } },
},
})
return (
<Card>
<CardHeader>
<CardTitle>Tasks ({tasks.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<input
type="checkbox"
defaultChecked={task.status === "DONE"}
className="h-4 w-4 rounded border-gray-300"
/>
<span
className={
task.status === "DONE" ? "text-muted-foreground line-through" : ""
}
>
{task.title}
</span>
</div>
<div className="flex items-center gap-2">
<Badge className={statusBadge[task.status]} variant="secondary">
{task.status.replace("_", " ")}
</Badge>
{task.assignee && (
<span className="text-xs text-muted-foreground">
{task.assignee.name}
</span>
)}
</div>
</div>
))}
{tasks.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
No tasks yet. Create your first task to get started.
</p>
)}
</div>
</CardContent>
</Card>
)
}
8. Router Groups and Layout Organization
(1) Route Groups Structure
src/app/
├── (auth)/ # Auth Routing Group(No sidebar)
│ └── auth/
│ ├── signin/
│ └── signup/
├── (dashboard)/ # Dashboard Routing Group(Has a sidebar)
│ ├── layout.tsx # Dashboard Layout(Sidebar + TopNav)
│ ├── dashboard/
│ │ └── page.tsx
│ └── projects/
│ └── [id]/
│ └── page.tsx
├── layout.tsx # Root Layout(html/body)
└── page.tsx # Home(Public)
▶ Example: Root Layout and Font Optimization
// src/app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
})
export const metadata: Metadata = {
title: {
default: "TaskFlow - Project Management",
template: "%s | TaskFlow",
},
description: "Enterprise project management platform for modern teams",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen bg-background font-sans antialiased">
{children}
</body>
</html>
)
}
9. Complete Example: Full Dashboard Page
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { TaskCompletionWidget } from "@/components/dashboard/task-completion-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { RecentProjectsWidget } from "@/components/dashboard/recent-projects-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
return (
<div className="space-y-6">
<Suspense fallback={<div className="h-24 animate-pulse rounded-lg bg-muted" />}>
<WelcomeBanner user={session.user} />
</Suspense>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<StatCardSkeleton />}>
<ProjectCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<MemberCountWidget />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<TaskCompletionWidget />
</Suspense>
</div>
<div className="grid gap-6 lg:grid-cols-7">
<div className="lg:col-span-4">
<Suspense
fallback={
<div className="h-96 animate-pulse rounded-lg bg-muted" />
}
>
<ActivityFeedWidget />
</Suspense>
</div>
<div className="lg:col-span-3">
<Suspense
fallback={
<div className="h-96 animate-pulse rounded-lg bg-muted" />
}
>
<RecentProjectsWidget />
</Suspense>
</div>
</div>
</div>
)
}
Expected output (when accessing /dashboard in a browser):
→ Saw it in an instant:Sidebar + Top Navigation + Breadcrumbs(Static shell)
→ 0.3s: Welcome banner appears
→ 0.5s: 4 A Statistical Card Template → Fill in the data(12 / 50 / 5 / 68%)
→ 1.2s: Activity Flow + List of Recent Projects
→ Fully Interactive Page
❓ FAQ
<Suspense> is component-level streaming loading. PPR can pre-generate static HTML during the build process, with dynamic content filled in via streaming; loading.tsx, on the other hand, is rendered entirely dynamically.generateStaticParams suitable for all project pages?Promise.all?Promise.all is the "one fails, all fail" strategy. For non-critical data (such as activity streams), using Promise.allSettled is safer—even if the activity stream fails, the statistics will still be displayed. An error boundary combined with a fallback provides a degraded experience.usePathname a client-side component?usePathname is a client-side hook. The Breadcrumb component is very small (it doesn’t fetch any data), so there are no performance issues with client-side rendering. If performance is a concern, you can implement it using the server-side headers().get("next-url"), but the code will be more complex./dashboard route, checking it again in the Layout serves as a "layered defense"—to prevent bypassing the check when directly accessing nested routes (such as /dashboard/projects/xxx). Next.js recommends performing authentication checks in both the layout and data retrieval layers.📖 Summary
- Dashboard Layout: Use Route Groups
(dashboard)to isolate route groups that require a sidebar - The PPR static shell wraps the dynamic widget using
<Suspense>and generates an HTML skeleton during build time Promise.allBy retrieving data from four independent data sources in parallel, the total time required was reduced from 8.2 seconds to 2.1 secondserror.tsxprovides both global and local error boundaries; thereset()function supports retriesloading.tsx+ Skeleton screen component eliminates the blank screen and improves LCP to 1.2 secondsgenerateStaticParamsPre-generate active project detail pages and update them on demand using ISR- The activity stream uses
Promise.allto perform parallel queries on Task and Comment, then merges and sorts the results
📝 Exercises
-
Basic Exercise (⭐): Implement the Dashboard page in the TaskFlow project, ensuring that the four statistics cards are rendered using PPR static shells. Run
npm run buildto verify that PPR is working (check the build log for the○ (Static)andλ (Dynamic)tags). -
Advanced Exercise (⭐⭐): Replace
Promise.allwithPromise.allSettledin the Activity Flow widget, and add an error message (display "Unable to load activity" when the activity flow fails to load, rather than causing the entire widget to crash). Write the code for the fallback UI. -
Challenge (⭐⭐⭐): Implement a fully functional project details page for TaskFlow—supporting SSR (ensuring real-time data) +
generateStaticParamsfor ISR (revalidate: 60) on the top 10 active projects, with SSR for all other projects. Addloading.tsxa skeleton screen +error.tsxborders + dynamically updated breadcrumbs. Use Lighthouse to compare the performance differences among the three strategies.



