Next.js: 最終プロジェクト: ダッシュボードと PPR

最終更新:2026-08-26

ダッシュボードは SaaS プラットフォームの「コックピット」です — 静的なフレームワークで一目でレイアウトを確認でき、動的なデータストリームが主要な指標をリアルタイムで埋めます。

1. 学習内容



2. あるフロントエンドアーキテクトの実話

(1) 課題:ダッシュボードの読み込みに 8 秒かかり、ユーザーが全員離脱した

Alice の TaskFlow はベータテスト中に 50 件のユーザーフィードバックを受け取りました — その 92% がダッシュボードの読み込み速度について不満を述べていました。問題は逐次的なデータ取得にありました:最初にプロジェクト数を取得 → 次にタスク数 → 次にメンバー数 → 最後にアクティビティフィード。これらの 4 つの API リクエストがカスケードブロックを引き起こし、合計 8.2 秒かかっていました。Charlie(マーケティング)は「10 秒待って、ページを閉じただけだ」と言いました。

(2) PPR 静的シェル + 並列データ収集のソリューション

PPR の静的シェルを使用してページスケルトンを即座にレンダリングし、次に Promise.all で 4 つのデータソースを並列に読み込みます。

TSX
// PPR 静的シェル + 並列データ取得
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) 成果

項目 直列読み込み PPR + 並列読み込み
ファーストスクリーンレンダリング時間 8.2 秒 0.3 秒(静的シェル)
データ読み込み完了 8.2 秒 2.1 秒(並列)
ユーザー体験 白画面 → リダイレクト スケルトン → 段階的読み込み
LCP 7.8 秒 1.2 秒
ユーザー維持率 8% 直帰率 2% 直帰率


3. グローバルナビゲーションレイアウト

(1) レイアウトアーキテクチャ図

100%
graph TB
    subgraph "ルートレイアウト"
        A[RootLayout<br/>html/body/fonts]
    end

    subgraph "ダッシュボードレイアウト"
        B[サイドバー<br/>組織<br/>ナビゲーション<br/>ユーザーメニュー]
        C[トップナビ<br/>検索バー<br/>通知<br/>パンくずリスト]
        D[メインコンテンツ<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) ダッシュボードレイアウトコンポーネント

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

▶ サンプル: サイドバーコンポーネント

💻 出力:

TEXT 📖 参照専用
サイドバーを含みます。
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} プロジェクト · {organization?._count.users} メンバー
          </p>
        </div>

        <NavItem href="/dashboard" icon="LayoutDashboard" label="ダッシュボード" />
        <NavItem href="/dashboard/projects" icon="FolderKanban" label="プロジェクト" />
        <NavItem href="/dashboard/tasks" icon="CheckSquare" label="タスク" />
        <NavItem href="/dashboard/team" icon="Users" label="チーム" />
        <NavItem href="/dashboard/settings" icon="Settings" label="設定" />

        <Separator className="my-4" />

        <p className="mb-2 px-3 text-xs font-medium text-muted-foreground">
          招待コード
        </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>
  )
}
💻 出力:

TEXT 📖 参照専用
レンダリング: TF | TaskFlow | 招待コード

▶ サンプル: トップナビゲーションバー

💻 出力:

TEXT 📖 参照専用
コンポーネントは説明された UI をブラウザにレンダリングします。
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>
  )
}
💻 出力:

TEXT 📖 参照専用
ヘッダーセクションのあるページ。
表示テキスト: \uD83D\uDD14 | 3 | \u2753

▶ サンプル: パンくずリストナビゲーション

💻 出力:

TEXT 📖 参照専用
ページは上記の説明に従ってレンダリングされ、UI は説明された動作に基づいて更新されます。
TSX
// src/components/layout/breadcrumb.tsx
"use client"

import { usePathname } from "next/navigation"
import Link from "next/link"

const routeLabels: Record<string, string> = {
  dashboard: "ダッシュボード",
  projects: "プロジェクト",
  tasks: "タスク",
  team: "チーム",
  settings: "設定",
}

export function Breadcrumb() {
  const pathname = usePathname()
  const segments = pathname.split("/").filter(Boolean)

  return (
    <nav aria-label="パンくずリスト" className="flex items-center gap-2 text-sm">
      <Link href="/dashboard" className="text-muted-foreground hover:text-foreground">
        ホーム
      </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>
  )
}
💻 出力:

TEXT 📖 参照専用
.map() を使用して動的リストをレンダリングします。
表示コンテンツ: ホーム | ) : (


4. ダッシュボード PPR 静的ケース

(1) PPR の動作原理

100%
sequenceDiagram
    participant B as ブラウザ
    participant N as Next.js サーバー
    participant DB as データベース

    B->>N: GET /dashboard
    N->>B: 静的シェル (HTML + CSS)<br/>スケルトンカードが表示
    B->>N: ストリーミング開始
    N->>DB: Promise.all<br/>(4 つの並列クエリ)
    DB-->>N: 結果
    N->>B: 動的コンテンツがスケルトンを埋める
    Note over B: 段階的拡張

(2) PPR の有効化

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

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

export default nextConfig

▶ サンプル: ダッシュボードページの PPR 実装

💻 出力:

TEXT 📖 参照専用
TypeScript モジュールが正常に実行されました。
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>
  )
}
💻 出力:

TEXT 📖 参照専用
静的シェルを即座にレンダリングします。動的セクションはデータが読み込まれるまで "}>" フォールバックを表示します。
表示コンテンツ: }> | }> | }>

(3) データ取得ウィジェットの実装

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">アクティブなプロジェクト</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">組織全体</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">合計タスク数</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} 未着手</span>
          <span className="text-blue-500">{inProgress} 進行中</span>
          <span className="text-green-500">{done} 完了</span>
        </div>
      </CardContent>
    </Card>
  )
}


5. 並列データ取得

(1) 直列処理と並列処理の比較

100%
graph LR
    subgraph "直列 (8.2s)"
        A1[Fetch A] --> A2[Fetch B] --> A3[Fetch C] --> A4[Fetch D]
    end

    subgraph "並列 (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

▶ サンプル: Promise.all を使用したアクティビティストリームの並列取得

💻 出力:

TEXT 📖 参照専用
並列ルートの図: @sidebar と @modal が名前付きスロットで同時にレンダリングされます。
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: `タスク「${t.title}」を作成しました`,
      project: t.project.name,
      time: t.createdAt,
    })),
    ...recentComments.map((c) => ({
      id: c.id,
      type: "comment_added" as const,
      user: c.author,
      description: `「${c.task.title}」にコメントしました`,
      project: "",
      time: c.createdAt,
    })),
  ]
    .sort((a, b) => b.time.getTime() - a.time.getTime())
    .slice(0, 10)

  return (
    <Card>
      <CardHeader>
        <CardTitle>アクティビティフィード</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("ja-JP", {
                    month: "short",
                    day: "numeric",
                    hour: "2-digit",
                    minute: "2-digit",
                  })}
                </p>
              </div>
            </div>
          ))}
          {activities.length === 0 && (
            <p className="text-sm text-muted-foreground">
              最近のアクティビティはありません
            </p>
          )}
        </div>
      </CardContent>
    </Card>
  )
}
💻 出力:

TEXT 📖 参照専用
recentTasks をマッピングしてリストをレンダリングし、各 t を表示します。
表示コンテンツ: アクティビティフィード

(2) データ取得戦略の比較

戦略 合計時間 実装の複雑さ 適したシナリオ
直列 await Sum(N) 簡単 データ依存関係あり
Promise.all Max(N) 中程度 独立したデータソース
Promise.allSettled Max(N) 高い 部分的な失敗を許容
ストリーミング + PPR TTFB ~0 高い ファーストスクリーン優先


6. エラーバウンダリーとローディングスケルトン

(1) エラーバウンダリーの階層

100%
graph TB
    A[RootLayout] --> B[error.tsx<br/>グローバルエラー]
    A --> C[DashboardLayout]
    C --> D[error.tsx<br/>ダッシュボードエラー]
    D --> E[ダッシュボードページ]
    E --> F[ウィジェット 1<br/>Suspense フォールバック]
    E --> G[ウィジェット 2<br/>Suspense フォールバック]
    E --> H[ウィジェット 3<br/>エラーバウンダリー]

    style B fill:#f8d7da
    style D fill:#f8d7da
    style F fill:#d4edda
    style G fill:#d4edda

▶ サンプル: グローバルエラーバウンダリー

💻 出力:

TEXT 📖 参照専用
エラーバウンダリー階層図: ルート → レイアウト → ページレベルのエラーハンドリング。
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">問題が発生しました</CardTitle>
          <CardDescription>
            ダッシュボードの読み込み中に予期しないエラーが発生しました。
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
            {error.message || "不明なエラー"}
          </div>
          {error.digest && (
            <p className="mt-2 text-xs text-muted-foreground">
              エラー ID: {error.digest}
            </p>
          )}
        </CardContent>
        <CardFooter>
          <Button onClick={() => reset()} variant="outline" className="w-full">
            再試行
          </Button>
        </CardFooter>
      </Card>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
レンダリング: 問題が発生しました | ダッシュボードの読み込み中に予期しないエラーが発生しました。


7. 静的プロジェクト詳細ページ

(1) 静的ページ生成戦略

戦略 生成タイミング データ鮮度 ビルド時間 適用シナリオ
SSG (generateStaticParams) ビルド時 ビルド時のスナップショット 遅い(N ページ) コンテンツがほとんど変わらない
ISR (revalidate) ビルド時 + オンデマンド TTL 分の遅延 遅い(N ページ) 定期的な更新
SSR (動的) リクエスト時 リアルタイム 速い(N 不要) リアルタイムデータ
PPR ビルド時 + ストリーミング 動的部分はリアルタイム 速い(静的シェル) 混合シナリオ


8. ルートグループとレイアウト構成

(1) ルートグループ構造

TEXT 📖 参照専用
src/app/
├── (auth)/                 # 認証ルートグループ(サイドバーなし)
│   └── auth/
│       ├── signin/
│       └── signup/
├── (dashboard)/            # ダッシュボードルートグループ(サイドバーあり)
│   ├── layout.tsx          # ダッシュボードレイアウト(サイドバー + トップナビ)
│   ├── dashboard/
│   │   └── page.tsx
│   └── projects/
│       └── [id]/
│           └── page.tsx
├── layout.tsx              # ルートレイアウト(html/body)
└── page.tsx                # ホーム(公開)


9. 完全な例: 完全なダッシュボードページ

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

ブラウザで /dashboard にアクセスした場合の期待される出力:

TEXT 📖 参照専用
→ 即座に表示: サイドバー + トップナビゲーション + パンくずリスト(静的シェル)
→ 0.3 秒: ウェルカムバナーが表示
→ 0.5 秒: 4 つの統計カードテンプレート → データが埋められる (12 / 50 / 5 / 68%)
→ 1.2 秒: アクティビティフロー + 最近のプロジェクトリスト
→ 完全にインタラクティブなページ

❓ よくある質問

Q PPR と loading.tsx の違いは何ですか?
A loading.tsx はページ全体のローディング状態を処理します(ページ全体の置き換え)。PPR の <Suspense> はコンポーネントレベルのストリーミングローディングです。PPR はビルドプロセス中に静的 HTML を事前生成し、動的コンテンツはストリーミングで埋められます。一方、loading.tsx は全体が動的にレンダリングされます。
Q generateStaticParams はすべてのプロジェクトページに適していますか?
A いいえ、適していません。プロジェクト数が非常に多い場合(10,000 以上)、ビルド時間が極端に長くなります。TaskFlow では、アクティブなプロジェクト(ACTIVE)に対してのみ静的生成を実行し、ISR によるオンデマンド更新と組み合わせています。極めて大規模なプロジェクトでは、SSR とキャッシング戦略の組み合わせをお勧めします。
Q Promise.all で 1 つのリクエストが失敗した場合、どうなりますか?
A Promise.all は「1 つが失敗するとすべてが失敗する」戦略です。重要でないデータ(アクティビティストリームなど)には、Promise.allSettled を使用する方が安全です。アクティビティストリームが失敗しても、統計データは引き続き表示されます。エラーバウンダリーとフォールバックを組み合わせることで、劣化した体験を提供できます。
Q usePathname を使用するパンくずリストコンポーネントはクライアントサイドコンポーネントですか?
A はい、usePathname はクライアントサイドフックです。パンくずリストコンポーネントは非常に小さく(データを取得しません)、クライアントサイドレンダリングによるパフォーマンスの問題はありません。パフォーマンスが懸念される場合は、サーバーサイドの headers().get("next-url") を使用して実装することもできますが、コードはより複雑になります。
Q ダッシュボードレイアウトでセッションを再度チェックするのはなぜですか?
A middleware.ts は既に /dashboard ルートを保護していますが、レイアウトでの再チェックは「多層防御」として機能します。ネストされたルート(/dashboard/projects/xxx など)に直接アクセスする際のチェックバイパスを防ぎます。Next.js は、レイアウトとデータ取得レイヤーの両方で認証チェックを実行することを推奨しています。
Q スケルトン画面のアニメーションは CLS(累積レイアウトシフト)に影響しますか?
A いいえ。スケルトン画面は実際のコンテンツと同じ寸法(高さ/幅)を事前に占有するため、レイアウトシフトを引き起こしません。これは PPR と loading.tsx の重要な利点であり、ユーザーはレイアウト構造を即座に確認でき、白画面遷移による CLS を排除できます。

📖 まとめ

📝 練習問題

  1. 基本問題 (⭐): TaskFlow プロジェクトにダッシュボードページを実装し、4 つの統計カードが PPR 静的シェルを使用してレンダリングされることを確認します。npm run build を実行して PPR が動作していることを確認します(ビルドログの ○ (Static)λ (Dynamic) タグを確認)。

  2. 応用問題 (⭐⭐): アクティビティフローウィジェットで Promise.allPromise.allSettled に置き換え、エラーメッセージを追加します(アクティビティフローの読み込みに失敗した場合、ウィジェット全体をクラッシュさせるのではなく「アクティビティを読み込めません」と表示する)。フォールバック UI のコードを記述します。

  3. 発展問題 (⭐⭐⭐): TaskFlow に完全に機能するプロジェクト詳細ページを実装します — SSR(リアルタイムデータ) + アクティブプロジェクト上位 10 件に対する generateStaticParams の ISR(revalidate: 60)をサポートし、その他のプロジェクトは SSR とします。loading.tsx のスケルトン画面 + error.tsx の境界 + 動的に更新されるパンくずリストを追加します。Lighthouse を使用して 3 つの戦略のパフォーマンスの違いを比較します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%