404 Not Found

404 Not Found


nginx

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


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.all to load 4 data sources in parallel.

TSX
// 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

100%
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

TSX
// 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

TSX
// 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

TSX
// 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

TSX
// 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

100%
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

TYPESCRIPT
// next.config.ts
import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

▶ Example: PPR Implementation on the Dashboard Page

TSX
// 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

TSX
// 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>
  )
}
TSX
// 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

100%
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

TSX
// 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

100%
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

TSX
// 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

TSX
// 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>
  )
}
TSX
// 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

TSX
// 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>
  )
}
TSX
// 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

TEXT
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

TSX
// 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

TSX
// 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):

TEXT
→ 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

Q What is the difference between PPR and loading.tsx?
A loading.tsx handles the loading state of the entire page (full-page replacement), while PPR’s <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.
Q Is generateStaticParams suitable for all project pages?
A No, it is not. If there are a large number of projects (10,000+), the build time will be very long. TaskFlow performs static generation only for active projects (ACTIVE) and combines this with on-demand updates via ISR. For extremely large-scale projects, we recommend using SSR combined with a caching strategy.
Q What happens if one request fails in Promise.all?
A 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.
Q Is the Breadcrumb component that uses usePathname a client-side component?
A Yes, 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.
Q Why is the session checked again in the Dashboard Layout?
A Although middleware.ts already protects the /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.
Q Do skeleton screen animations affect CLS (Cumulative Layout Shift)?
A No. The skeleton screen occupies the same dimensions (height/width) as the actual content in advance, so it does not cause layout shifts. This is a key advantage of PPR and loading.tsx—it allows users to see the layout structure immediately, eliminating CLS caused by white-screen transitions.

📖 Summary

📝 Exercises

  1. 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 build to verify that PPR is working (check the build log for the ○ (Static) and λ (Dynamic) tags).

  2. Advanced Exercise (⭐⭐): Replace Promise.all with Promise.allSettled in 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.

  3. Challenge (⭐⭐⭐): Implement a fully functional project details page for TaskFlow—supporting SSR (ensuring real-time data) + generateStaticParams for ISR (revalidate: 60) on the top 10 active projects, with SSR for all other projects. Add loading.tsx a skeleton screen + error.tsx borders + dynamically updated breadcrumbs. Use Lighthouse to compare the performance differences among the three strategies.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏