Next.js: 最終プロジェクト: ダッシュボードと PPR
最終更新:2026-08-26
ダッシュボードは SaaS プラットフォームの「コックピット」です — 静的なフレームワークで一目でレイアウトを確認でき、動的なデータストリームが主要な指標をリアルタイムで埋めます。
1. 学習内容
- グローバルナビゲーションレイアウトの構築(サイドバー + トップナビ + パンくずリスト)
- ダッシュボード PPR 静的シェルモード(
<Suspense>動的ウィジェット) Promise.all複数の独立したデータソースからの並列データ取得error.tsxグローバルエラーバウンダリー +loading.tsxスケルトン画面デザインgenerateStaticParams静的プロジェクト詳細ページの事前生成- アクティビティストリームデータの集計クエリ
2. あるフロントエンドアーキテクトの実話
(1) 課題:ダッシュボードの読み込みに 8 秒かかり、ユーザーが全員離脱した
Alice の TaskFlow はベータテスト中に 50 件のユーザーフィードバックを受け取りました — その 92% がダッシュボードの読み込み速度について不満を述べていました。問題は逐次的なデータ取得にありました:最初にプロジェクト数を取得 → 次にタスク数 → 次にメンバー数 → 最後にアクティビティフィード。これらの 4 つの API リクエストがカスケードブロックを引き起こし、合計 8.2 秒かかっていました。Charlie(マーケティング)は「10 秒待って、ページを閉じただけだ」と言いました。
(2) PPR 静的シェル + 並列データ収集のソリューション
PPR の静的シェルを使用してページスケルトンを即座にレンダリングし、次に
Promise.allで 4 つのデータソースを並列に読み込みます。
// 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) レイアウトアーキテクチャ図
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) ダッシュボードレイアウトコンポーネント
// 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>
)
}
▶ サンプル: サイドバーコンポーネント
サイドバーを含みます。
// 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>
)
}
レンダリング: TF | TaskFlow | 招待コード
▶ サンプル: トップナビゲーションバー
コンポーネントは説明された UI をブラウザにレンダリングします。
// 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>
)
}
ヘッダーセクションのあるページ。
表示テキスト: \uD83D\uDD14 | 3 | \u2753
▶ サンプル: パンくずリストナビゲーション
ページは上記の説明に従ってレンダリングされ、UI は説明された動作に基づいて更新されます。
// 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>
)
}
.map() を使用して動的リストをレンダリングします。
表示コンテンツ: ホーム | ) : (
4. ダッシュボード PPR 静的ケース
(1) PPR の動作原理
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 の有効化
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
▶ サンプル: ダッシュボードページの PPR 実装
TypeScript モジュールが正常に実行されました。
// 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) データ取得ウィジェットの実装
// 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>
)
}
// 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) 直列処理と並列処理の比較
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 を使用したアクティビティストリームの並列取得
並列ルートの図: @sidebar と @modal が名前付きスロットで同時にレンダリングされます。
// 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>
)
}
recentTasks をマッピングしてリストをレンダリングし、各 t を表示します。
表示コンテンツ: アクティビティフィード
(2) データ取得戦略の比較
| 戦略 | 合計時間 | 実装の複雑さ | 適したシナリオ |
|---|---|---|---|
| 直列 await | Sum(N) | 簡単 | データ依存関係あり |
| Promise.all | Max(N) | 中程度 | 独立したデータソース |
| Promise.allSettled | Max(N) | 高い | 部分的な失敗を許容 |
| ストリーミング + PPR | TTFB ~0 | 高い | ファーストスクリーン優先 |
6. エラーバウンダリーとローディングスケルトン
(1) エラーバウンダリーの階層
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
▶ サンプル: グローバルエラーバウンダリー
エラーバウンダリー階層図: ルート → レイアウト → ページレベルのエラーハンドリング。
// 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>
)
}
レンダリング: 問題が発生しました | ダッシュボードの読み込み中に予期しないエラーが発生しました。
7. 静的プロジェクト詳細ページ
(1) 静的ページ生成戦略
| 戦略 | 生成タイミング | データ鮮度 | ビルド時間 | 適用シナリオ |
|---|---|---|---|---|
| SSG (generateStaticParams) | ビルド時 | ビルド時のスナップショット | 遅い(N ページ) | コンテンツがほとんど変わらない |
| ISR (revalidate) | ビルド時 + オンデマンド | TTL 分の遅延 | 遅い(N ページ) | 定期的な更新 |
| SSR (動的) | リクエスト時 | リアルタイム | 速い(N 不要) | リアルタイムデータ |
| PPR | ビルド時 + ストリーミング | 動的部分はリアルタイム | 速い(静的シェル) | 混合シナリオ |
8. ルートグループとレイアウト構成
(1) ルートグループ構造
src/app/
├── (auth)/ # 認証ルートグループ(サイドバーなし)
│ └── auth/
│ ├── signin/
│ └── signup/
├── (dashboard)/ # ダッシュボードルートグループ(サイドバーあり)
│ ├── layout.tsx # ダッシュボードレイアウト(サイドバー + トップナビ)
│ ├── dashboard/
│ │ └── page.tsx
│ └── projects/
│ └── [id]/
│ └── page.tsx
├── layout.tsx # ルートレイアウト(html/body)
└── page.tsx # ホーム(公開)
9. 完全な例: 完全なダッシュボードページ
// 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 にアクセスした場合の期待される出力:
→ 即座に表示: サイドバー + トップナビゲーション + パンくずリスト(静的シェル)
→ 0.3 秒: ウェルカムバナーが表示
→ 0.5 秒: 4 つの統計カードテンプレート → データが埋められる (12 / 50 / 5 / 68%)
→ 1.2 秒: アクティビティフロー + 最近のプロジェクトリスト
→ 完全にインタラクティブなページ
❓ よくある質問
<Suspense> はコンポーネントレベルのストリーミングローディングです。PPR はビルドプロセス中に静的 HTML を事前生成し、動的コンテンツはストリーミングで埋められます。一方、loading.tsx は全体が動的にレンダリングされます。generateStaticParams はすべてのプロジェクトページに適していますか?Promise.all で 1 つのリクエストが失敗した場合、どうなりますか?Promise.all は「1 つが失敗するとすべてが失敗する」戦略です。重要でないデータ(アクティビティストリームなど)には、Promise.allSettled を使用する方が安全です。アクティビティストリームが失敗しても、統計データは引き続き表示されます。エラーバウンダリーとフォールバックを組み合わせることで、劣化した体験を提供できます。usePathname を使用するパンくずリストコンポーネントはクライアントサイドコンポーネントですか?usePathname はクライアントサイドフックです。パンくずリストコンポーネントは非常に小さく(データを取得しません)、クライアントサイドレンダリングによるパフォーマンスの問題はありません。パフォーマンスが懸念される場合は、サーバーサイドの headers().get("next-url") を使用して実装することもできますが、コードはより複雑になります。/dashboard ルートを保護していますが、レイアウトでの再チェックは「多層防御」として機能します。ネストされたルート(/dashboard/projects/xxx など)に直接アクセスする際のチェックバイパスを防ぎます。Next.js は、レイアウトとデータ取得レイヤーの両方で認証チェックを実行することを推奨しています。📖 まとめ
- ダッシュボードレイアウト:ルートグループ
(dashboard)を使用してサイドバーが必要なルートグループを分離します。 - PPR 静的シェルは
<Suspense>を使用して動的ウィジェットをラップし、ビルド時に HTML スケルトンを生成します。 Promise.allは 4 つの独立したデータソースから並列にデータを取得し、合計時間を 8.2 秒から 2.1 秒に短縮しました。error.tsxはグローバルとローカルの両方のエラーバウンダリーを提供し、reset()関数は再試行をサポートします。loading.tsx+ スケルトン画面コンポーネントは空白画面を排除し、LCP を 1.2 秒に改善します。generateStaticParamsはアクティブなプロジェクト詳細ページを事前生成し、ISR でオンデマンド更新します。- アクティビティストリームは
Promise.allを使用して Task と Comment の並列クエリを実行し、結果をマージしてソートします。
📝 練習問題
-
基本問題 (⭐): TaskFlow プロジェクトにダッシュボードページを実装し、4 つの統計カードが PPR 静的シェルを使用してレンダリングされることを確認します。
npm run buildを実行して PPR が動作していることを確認します(ビルドログの○ (Static)とλ (Dynamic)タグを確認)。 -
応用問題 (⭐⭐): アクティビティフローウィジェットで
Promise.allをPromise.allSettledに置き換え、エラーメッセージを追加します(アクティビティフローの読み込みに失敗した場合、ウィジェット全体をクラッシュさせるのではなく「アクティビティを読み込めません」と表示する)。フォールバック UI のコードを記述します。 -
発展問題 (⭐⭐⭐): TaskFlow に完全に機能するプロジェクト詳細ページを実装します — SSR(リアルタイムデータ) + アクティブプロジェクト上位 10 件に対する
generateStaticParamsの ISR(revalidate: 60)をサポートし、その他のプロジェクトは SSR とします。loading.tsxのスケルトン画面 +error.tsxの境界 + 動的に更新されるパンくずリストを追加します。Lighthouse を使用して 3 つの戦略のパフォーマンスの違いを比較します。