Next.js: パフォーマンス分析と監視
最終更新:2026-08-26
パフォーマンスは重要な属性です — 100 ミリ秒の遅延で 7% のユーザーを失う可能性があるインターネット時代において、パフォーマンス最適化は製品の競争力の中核指標です。
1. 学習内容
- Lighthouse CI を統合し、パフォーマンスしきい値を設定する(lighthouserc.js)
- @next/bundle-analyzer を使用して JavaScript バンドルサイズを可視化する
- Web Vitals ライブラリを使用して実際のユーザーの Core Web Vitals データを収集する
- @vercel/speed-insights と PostHog を統合してパフォーマンストラッキングを行う
- /instrumentation.ts でカスタム OpenTelemetry トレースを作成する
- 遅いルートを特定し最適化する
2. あるパフォーマンスエンジニアの実話
(1) 課題:Lighthouse スコアが 45、ユーザーが離脱している
Diana は TaskFlow プラットフォームのパフォーマンスエンジニアです。彼女は最近、ユーザー行動分析レポートを受け取りました:
| 指標 | 現在値 | 業界ベンチマーク | 影響 |
|---|---|---|---|
| LCP | 4.8s | < 2.5s | ユーザー直帰率 45% |
| FID/INP | 320 ms | < 200 ms | 操作がカクカク感じる |
| CLS | 0.35 | < 0.1 | レイアウトシフトによる誤クリック |
| バンドルサイズ | 1.2 MB | < 500 KB | ファーストスクリーン読み込みが遅い |
さらに悪いことに、彼女は次の質問に答えられませんでした:
- 本番環境の LCP は過去 1 週間で上がったのか下がったのか?
- どのページが一番遅いのか?
- 新しくリリースされたコードはパフォーマンス低下を引き起こしているか?
(2) パフォーマンス監視システムのソリューション
Diana は包括的なパフォーマンス監視システムを確立しました:
// リアルユーザー監視 (RUM)
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals(metric => {
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric)
})
})
}
(3) 成果
| 項目 | 最適化前 | 最適化後 |
|---|---|---|
| LCP | 4.8s | 1.2s |
| バンドルサイズ | 1.2 MB | 380 KB |
| Lighthouse スコア | 45/100 | 92/100 |
| パフォーマンス低下検出 | デプロイ後に発見 | PR フェーズで阻止 |
3. Lighthouse CI
Lighthouse CI は CI/CD パイプラインで Lighthouse 監査を自動実行し、パフォーマンス予算しきい値を設定します。
graph TB
A[CI パイプライン] --> B[Lighthouse CI]
B --> C[Lighthouse 監査の実行]
C --> D{パフォーマンス予算との比較}
D -->|合格| E[パイプライン続行]
D -->|失敗| F[パイプライン中断]
F --> G[PR に失敗をコメント]
G --> H[開発者がローカルで最適化]
style A fill:#cce5ff
style C fill:#fff3cd
style D fill:#f8d7da
style E fill:#d4edda
(1) lighthouserc.js 設定
// lighthouserc.js
module.exports = {
ci: {
collect: {
// 収集回数(中央値を取得)
numberOfRuns: 3,
// 監査対象ページ
url: [
'http://localhost:3000',
'http://localhost:3000/login',
'http://localhost:3000/dashboard',
'http://localhost:3000/projects',
'http://localhost:3000/projects/p1'
],
// Next.js 開発サーバーの起動
startServerCommand: 'npm run start -p 3000',
startServerReadyPattern: 'ready started server',
// デバイスシミュレーション
settings: {
preset: 'desktop',
throttling: {
cpuSlowdownMultiplier: 4,
downloadThroughputKbps: 10000,
uploadThroughputKbps: 5000,
rttMs: 40
}
}
},
assert: {
// パフォーマンスベースのアクセス制御
budgets: [
{
path: '/',
resourceSizes: [
{ resourceType: 'total', budget: 500 * 1024 }, // 500KB
{ resourceType: 'script', budget: 200 * 1024 }, // 200KB
{ resourceType: 'image', budget: 150 * 1024 } // 150KB
],
resourceCounts: [
{ resourceType: 'script', budget: 15 },
{ resourceType: 'stylesheet', budget: 5 },
{ resourceType: 'image', budget: 20 }
]
}
],
// Lighthouse スコアしきい値
assertions: {
// カテゴリスコア
'categories:performance': ['warn', { minScore: 0.9 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
// Core Web Vitals
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
// その他の主要指標
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'interactive': ['warn', { maxNumericValue: 3500 }],
'max-potential-fid': ['warn', { maxNumericValue: 100 }],
// ベストプラクティス
'uses-http2': ['error'],
'uses-responsive-images': ['error'],
'offscreen-images': ['error'],
'unused-javascript': ['warn', { maxNumericValue: 50 * 1024 }],
'unused-css-rules': ['warn', { maxNumericValue: 10 * 1024 }],
'uses-optimized-images': ['error'],
'uses-text-compression': ['error'],
'uses-rel-preconnect': ['warn'],
'uses-rel-preload': ['warn'],
'efficient-animated-content': ['warn'],
'total-byte-weight': ['error', { maxNumericValue: 500 * 1024 }]
}
},
upload: {
target: 'temporary-public-storage'
},
server: {
// 外部リンクを許可(CI 環境)
allowStaticServer: true
}
}
}
(2) CI 統合
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 依存関係のインストール
run: npm ci
- name: アプリケーションのビルド
run: npm run build
- name: Lighthouse CI の実行
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_TOKEN }}
▶ サンプル: Lighthouse CI レポートの読み方
Lighthouse CI Results (3 回実行、中央値)
URL: http://localhost:3000/dashboard
┌──────────────┬─────────┬─────────┬──────┐
│ カテゴリ │ スコア │ 目標 │ 合格 │
├──────────────┼─────────┼─────────┼──────┤
│ Performance │ 92 │ ≥ 90 │ ✅ │
│ Accessibility│ 95 │ ≥ 90 │ ✅ │
│ Best Practice│ 100 │ ≥ 90 │ ✅ │
│ SEO │ 100 │ ≥ 90 │ ✅ │
└──────────────┴─────────┴─────────┴──────┘
Core Web Vitals
LCP: 1,423 ms (≤ 2,500 ms) ✅
TBT: 87 ms (≤ 200 ms) ✅
CLS: 0.05 (≤ 0.1) ✅
予算
合計 KB: 382 / 500 KB ✅
スクリプト: 15 / 15 ✅
画像: 4 / 20 ✅
4. Bundle Analyzer
@next/bundle-analyzer は圧縮された JavaScript モジュールのサイズを可視化し、大きすぎる依存関係を特定するのに役立ちます。
graph LR
A[ビルドプロセス] --> B[Bundle Analyzer プラグイン]
B --> C[ツリーマップ HTML を生成]
C --> D[ブラウザで開いて分析]
D --> E{大きなモジュールの特定}
E --> F[tree-shaking が効いていない]
E --> G[重複依存関係]
E --> H[大きすぎるライブラリが読み込まれている]
style A fill:#cce5ff
style C fill:#d4edda
(1) Bundle Analyzer の設定
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true,
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
defaultSizes: 'gzip'
})
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
productionBrowserSourceMaps: false,
swcMinify: true
}
module.exports = withBundleAnalyzer(nextConfig)
(2) 操作分析
{
"scripts": {
"analyze": "ANALYZE=true npm run build",
"analyze:server": "ANALYZE=true npm run build && npx serve .next/analyze"
}
}
▶ サンプル: バンドル分析レポートの読み方
ANALYZE=true npm run build
バンドルレポート — クライアント/gzip
モジュール サイズ 全体に占める割合
├── node_modules 280 KB 73.3%
│ ├── @radix-ui 85 KB 22.3%
│ ├── react-dom 52 KB 13.6%
│ ├── react 18 KB 4.7%
│ ├── date-fns 45 KB 11.8%
│ ├── recharts 32 KB 8.4%
│ └── lodash 28 KB 7.3%
├── components/ 72 KB 18.8%
│ ├── Dashboard.tsx 18 KB 4.7%
│ ├── ProjectList.tsx 12 KB 3.1%
│ ├── TaskCard.tsx 8 KB 2.1%
│ └── ...
├── pages/ 22 KB 5.8%
└── lib/ 8 KB 2.1%
合計: 382 KB (gzip)
(3) バンドル最適化戦略
| 戦略 | 方法 | 期待される削減 |
|---|---|---|
| 動的インポート | const Chart = dynamic(() => import('./Chart')) |
-280 KB 初期ロード |
| ツリーシェイキング | CJS の代わりに ESM インポートを使用 | -20% デッドコード |
| コード分割 | ルートごとの自動分割(Next.js デフォルト) | -30% ファーストスクリーン JavaScript |
| 依存関係の置き換え | date-fns → dayjs(45KB → 6KB) |
-39 KB |
| 遅延読み込み | React.lazy + Suspense |
-85 KB インタラクションライブラリ |
5. Core Web Vitals リアルユーザー監視
Lighthouse はラボデータを提供しますが、実際のユーザー体験を反映できるのはリアルユーザー監視(RUM)だけです。
graph TB
A[ユーザーのブラウザ] --> B[Web Vitals データ収集]
B --> C{指標タイプ}
C --> D[LCP 最大コンテンツ描画]
C --> E[INP 次の描画までの対話]
C --> F[CLS 累積レイアウトシフト]
C --> G[FCP 初期コンテンツ描画]
C --> H[TTFB 最初のバイトまでの時間]
D --> I[分析サービスにレポート]
E --> I
F --> I
G --> I
H --> I
I --> J[PostHog / GA4 / 自作]
J --> K[ダッシュボード可視化]
style B fill:#cce5ff
style I fill:#fff3cd
style K fill:#d4edda
(1) Web Vitals レポートコンポーネント
// src/components/WebVitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type MetricType = {
id: string
name: string
value: number
rating: 'good' | 'needs-improvement' | 'poor'
delta: number
}
export function WebVitals() {
useReportWebVitals((metric: MetricType) => {
// 分析サービスにレポート
const body = {
metric_name: metric.name,
metric_value: metric.value,
metric_rating: metric.rating,
metric_delta: metric.delta,
url: window.location.pathname,
user_agent: navigator.userAgent,
device_type: getDeviceType(),
connection: (navigator as any).connection?.effectiveType || 'unknown',
timestamp: new Date().toISOString()
}
// sendBeacon を使用してページアンロード時もデータを確実にレポート
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify(body))
} else {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(body),
keepalive: true
})
}
})
return null
}
function getDeviceType(): string {
const width = window.innerWidth
if (width < 768) return 'mobile'
if (width < 1024) return 'tablet'
return 'desktop'
}
(2) Vitals API ルート(データ保存)
// src/app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
try {
const data = await request.json()
// データベースに保存
await prisma.webVital.create({
data: {
metricName: data.metric_name,
metricValue: data.metric_value,
metricRating: data.metric_rating,
url: data.url,
deviceType: data.device_type,
connection: data.connection,
userAgent: data.user_agent,
timestamp: new Date(data.timestamp)
}
})
// 指標が poor の場合、アラートをトリガー
if (data.metric_rating === 'poor') {
await checkAlertThresholds(data)
}
return NextResponse.json({ ok: true }, { status: 200 })
} catch (error) {
console.error('Web Vitals の保存に失敗しました:', error)
return NextResponse.json({ ok: false }, { status: 500 })
}
}
async function checkAlertThresholds(metric: any) {
// 過去 5 分間の、そのパスの poor 指標数を確認
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000)
const poorCount = await prisma.webVital.count({
where: {
metricName: metric.metric_name,
metricRating: 'poor',
url: metric.url,
timestamp: { gte: fiveMinAgo }
}
})
// しきい値を超えた場合、アラートを送信
if (poorCount > 10) {
// await sendAlert(`${metric.url} でパフォーマンス低下を検出: ${metric.metric_name} = ${metric.metric_value}`)
console.warn(`アラート: ${metric.url} で ${poorCount} 件の poor ${metric.metric_name}`)
}
}
(3) パフォーマンスダッシュボードクエリ
// src/app/dashboard/performance/page.tsx
import { prisma } from '@/lib/prisma'
async function getPerformanceSummary() {
const today = new Date()
today.setHours(0, 0, 0, 0)
const vitals = await prisma.webVital.groupBy({
by: ['metricName'],
where: {
timestamp: { gte: today }
},
_avg: {
metricValue: true
},
_count: true
})
// good / needs-improvement / poor の比率を計算
const ratings = await prisma.webVital.groupBy({
by: ['metricRating'],
where: {
timestamp: { gte: today }
},
_count: true
})
return { vitals, ratings }
}
▶ サンプル: Web Vitals データの読み方
本日の Core Web Vitals (2026-07-06)
指標 │ P75 │ P95 │ Good % │ Poor %
─────────────┼─────────┼─────────┼─────────┼───────
LCP │ 1,234ms │ 3,567ms │ 85.2% │ 5.1%
INP │ 98ms │ 245ms │ 91.3% │ 2.8%
CLS │ 0.05 │ 0.18 │ 88.7% │ 4.2%
FCP │ 823ms │ 1,945ms │ 90.1% │ 3.5%
TTFB │ 345ms │ 890ms │ 92.4% │ 2.1%
遅いルート TOP 5
/dashboard LCP: 3.2s (200 訪問)
/projects/p1/tasks LCP: 2.8s (150 訪問)
/reports LCP: 2.6s (80 訪問)
/settings LCP: 2.1s (60 訪問)
/analytics LCP: 1.9s (120 訪問)
6. PostHog Speed Insights 統合
PostHog は、セッション記録、フィーチャーフラグ、Speed Insights を内蔵したオープンソースの製品分析プラットフォームです。
(1) クライアント統合
// src/components/PostHogProvider.tsx
'use client'
import { posthog } from 'posthog-js'
import { PostHogProvider as PHProvider, usePostHog } from 'posthog-js/react'
import { useEffect } from 'react'
import { useReportWebVitals } from 'next/web-vitals'
if (typeof window !== 'undefined') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
capture_pageview: false,
capture_performance: true, // Web Vitals を自動キャプチャ
loaded: (ph) => {
if (process.env.NODE_ENV === 'development') ph.opt_out_capturing()
}
})
}
export function PostHogWebVitals() {
const posthog = usePostHog()
useReportWebVitals((metric) => {
posthog.capture('$web_vitals', {
$metric_name: metric.name,
$metric_value: metric.value,
$metric_rating: metric.rating,
$pathname: window.location.pathname,
$device: navigator.userAgent
})
})
return null
}
export function PHProvider({ children }: { children: React.ReactNode }) {
return <PHProvider client={posthog}>{children}</PHProvider>
}
(2) ルートレイアウト統合
// src/app/layout.tsx
import { PHProvider, PostHogWebVitals } from '@/components/PostHogProvider'
import { WebVitals } from '@/components/WebVitals'
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html lang="ja">
<body>
<PHProvider>
{children}
<WebVitals />
<PostHogWebVitals />
</PHProvider>
</body>
</html>
)
}
7. /instrumentation.ts カスタムトレーシング
Next.js 16 は instrumentation.ts を通じて OpenTelemetry トレースの登録をサポートし、サーバーサイドのパフォーマンスと遅いルートを監視します。
// src/instrumentation.ts
// ============================================
// Next.js 16 カスタムテレメトリー
// ============================================
import { registerOTel } from '@vercel/otel'
export async function register() {
registerOTel({
serviceName: 'taskflow',
attributes: {
'deployment.environment': process.env.NODE_ENV,
'service.version': process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || 'unknown'
}
})
}
(1) カスタム Span トラッキング
// src/lib/tracing.ts
// カスタムトラッキング Span
const SPAN_NAMES = {
DATABASE_QUERY: 'db.query',
EXTERNAL_API: 'http.request',
CACHE_READ: 'cache.read',
CACHE_WRITE: 'cache.write',
RENDER_COMPONENT: 'component.render'
} as const
export async function trace<T>(
spanName: string,
fn: () => Promise<T>,
attributes?: Record<string, string>
): Promise<T> {
// OpenTelemetry が利用不可の場合は直接実行
if (typeof (globalThis as any).performance === 'undefined') {
return fn()
}
const start = performance.now()
try {
const result = await fn()
const duration = performance.now() - start
// ログシステムに記録
if (duration > 100) {
console.warn(
`[TRACE] ${spanName} が ${duration.toFixed(2)}ms で完了しました`,
{ attributes, duration }
)
}
return result
} catch (error) {
const duration = performance.now() - start
console.error(`[TRACE] ${spanName} が ${duration.toFixed(2)}ms 後に失敗しました`, error)
throw error
}
}
(2) Server Action での使用
// src/actions/task.ts
'use server'
import { trace } from '@/lib/tracing'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function createTask(formData: FormData) {
return trace('server-action.createTask', async () => {
const title = formData.get('title') as string
await trace('db.query.create-task', () =>
prisma.task.create({
data: { title, projectId: formData.get('projectId') as string, status: 'TODO' }
})
)
revalidatePath('/projects')
return { success: true }
})
}
▶ サンプル: サーバーコンポーネントトレーシング
Server Action が実行され、revalidatePath() を呼び出してページキャッシュを更新します。
// src/app/projects/page.tsx
import { trace } from '@/lib/tracing'
import { prisma } from '@/lib/prisma'
async function getProjects() {
return trace('db.query.list-projects', async () => {
const projects = await prisma.project.findMany({
include: { _count: { select: { tasks: true } } },
orderBy: { updatedAt: 'desc' },
take: 50
})
return projects
})
}
export default async function ProjectsPage() {
const start = performance.now()
const projects = await getProjects()
const fetchTime = performance.now() - start
return (
<div>
<p data-testid="fetch-time">
{fetchTime.toFixed(0)}ms でデータを取得しました({projects.length} プロジェクト)
</p>
{/* プロジェクトのレンダリング... */}
</div>
)
}
セクションに記載されている ▶ サンプル: サーバーコンポーネントトレーシング コンポーネントの UI をレンダリングします。
8. 遅いルートの特定と最適化
(1) 遅いルートのログミドルウェア
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const start = Date.now()
const response = NextResponse.next()
// レスポンス完了後にかかった時間を記録
response.headers.set('X-Response-Time', '0')
// AsyncLocalStorage またはカスタムイベントを使用
process.nextTick(() => {
const duration = Date.now() - start
// 遅いリクエストをログに記録
if (duration > 1000) {
console.warn(
`[SLOW ROUTE] ${request.method} ${request.nextUrl.pathname} ` +
`${duration}ms かかりました`
)
}
})
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
}
(2) 遅いルート分析テーブル
| ルート | 平均応答時間 | P95 | リクエスト量 | 最適化戦略 |
|---|---|---|---|---|
/dashboard |
1,234 ms | 3,567 ms | 10,000/日 | PPR 静的シェル + Suspense 分割 |
/reports |
2,891 ms | 5,200 ms | 500/日 | ISR revalidate=300 を追加、レポートをキャッシュ |
/api/search |
1,567 ms | 4,100 ms | 8,000/日 | Redis キャッシュとページネーションを追加 |
/projects/[id]/tasks |
892 ms | 2,100 ms | 6,000/日 | データベースインデックスを追加し N+1 クエリを制限 |
(3) パフォーマンス最適化チェックリスト
// 1. 重いコンポーネントの動的インポート
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <div className="animate-pulse h-64" />,
ssr: false // SEO が不要なコンポーネントの SSR を無効化
})
// 2. 画像最適化
import Image from 'next/image'
<Image
src="/hero.webp"
alt="Hero"
width={1200}
height={630}
priority // 注目画像に priority を追加
placeholder="blur"
blurDataURL="data:image/webp;base64,..."
/>
// 3. 並列データ取得
const [projects, tasks, members] = await Promise.all([
getProjects(),
getTasks(),
getMembers()
])
// 4. キャッシュヘッダーの追加
export const revalidate = 300 // ISR 5 分
export const dynamic = 'force-static'
// 5. レスポンス圧縮
// next.config.js
compress: true
// 6. サードパーティソースへの事前接続
<link rel="preconnect" href="https://api.posthog.com" />
9. 完全な例: パフォーマンス監視ダッシュボード
// src/app/performance/page.tsx
// ============================================
// パフォーマンス監視ダッシュボード
// ============================================
import { prisma } from '@/lib/prisma'
// --- 1. データ収集 ---
async function getPerformanceData() {
const today = new Date()
today.setHours(0, 0, 0, 0)
const weekAgo = new Date(today)
weekAgo.setDate(weekAgo.getDate() - 7)
// 本日の指標
const todayVitals = await prisma.webVital.groupBy({
by: ['metricName'],
where: {
timestamp: { gte: today },
metricName: { in: ['LCP', 'INP', 'CLS', 'FCP', 'TTFB'] }
},
_avg: { metricValue: true },
_count: true
})
// 遅いルート TOP 10
const slowRoutes = await prisma.webVital.groupBy({
by: ['url'],
where: {
timestamp: { gte: weekAgo },
metricRating: 'poor'
},
_count: true,
_avg: { metricValue: true },
orderBy: { _avg: { metricValue: 'desc' } },
take: 10
})
// デバイス分布
const deviceStats = await prisma.webVital.groupBy({
by: ['deviceType'],
where: { timestamp: { gte: today } },
_count: true
})
// 日次トレンド
const dailyTrend = await prisma.$queryRaw`
SELECT
DATE(timestamp) as day,
AVG(CASE WHEN metric_name = 'LCP' THEN metric_value END) as avg_lcp,
AVG(CASE WHEN metric_name = 'CLS' THEN metric_value END) as avg_cls
FROM web_vitals
WHERE timestamp >= ${weekAgo}
GROUP BY DATE(timestamp)
ORDER BY day ASC
`
return { todayVitals, slowRoutes, deviceStats, dailyTrend }
}
// --- 2. パフォーマンスカードコンポーネント ---
function MetricCard({ name, value, rating, count }: {
name: string; value: number; rating: string; count: number
}) {
const colorMap = {
good: 'text-green-600',
'needs-improvement': 'text-yellow-600',
poor: 'text-red-600'
}
const formatValue = (metric: string, val: number) => {
if (metric === 'CLS') return val.toFixed(2)
return `${val.toFixed(0)}ms`
}
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-500">{name}</h3>
<p className={`text-2xl font-bold ${colorMap[rating as keyof typeof colorMap] || ''}`}>
{formatValue(name, value)}
</p>
<p className="text-xs text-gray-400">本日 {count} サンプル</p>
</div>
)
}
// --- 3. ページコンポーネント ---
export default async function PerformancePage() {
const data = await getPerformanceData()
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">パフォーマンスダッシュボード</h1>
{/* 本日の CWV */}
<div className="grid grid-cols-5 gap-4">
{data.todayVitals.map(v => (
<MetricCard
key={v.metricName}
name={v.metricName}
value={v._avg.metricValue || 0}
rating={
(v._avg.metricValue || 0) < 2500 ? 'good' :
(v._avg.metricValue || 0) < 4000 ? 'needs-improvement' : 'poor'
}
count={v._count}
/>
))}
</div>
{/* 遅いルート */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">遅いルート(過去 7 日間)</h2>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500">
<th className="pb-2">ルート</th>
<th className="pb-2">平均応答</th>
<th className="pb-2">Poor サンプル</th>
</tr>
</thead>
<tbody>
{data.slowRoutes.map(r => (
<tr key={r.url} className="border-t">
<td className="py-2 font-mono">{r.url}</td>
<td className="py-2">{r._avg.metricValue?.toFixed(0)}ms</td>
<td className="py-2 text-red-600">{r._count}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* デバイス分布 */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">デバイス分布</h2>
<div className="flex gap-8">
{data.deviceStats.map(d => (
<div key={d.deviceType}>
<span className="text-2xl font-bold">{d._count}</span>
<span className="text-gray-500 ml-2">{d.deviceType}</span>
</div>
))}
</div>
</div>
</div>
)
}
❓ よくある質問
useReportWebVitals と @vercel/speed-insights は競合しますか?useReportWebVitals は Next.js に組み込まれた Web Vitals のコールバックで、独自の分析サービスにデータをレポートするために使用できます。@vercel/speed-insights は Vercel が提供する有料サービスで、RUM データを自動収集して表示します。両方を同時に使用しても互いに干渉することはありません。/instrumentation.ts と middleware.ts の違いは何ですか?instrumentation.ts はアプリケーション起動時(サーバー起動時)に一度だけ実行され、OpenTelemetry や Sentry などのグローバルテレメトリの登録に使用されます。middleware.ts は各リクエストの前に実行され、リクエストのインターセプト、リダイレクト、ヘッダー注入に使用されます。パフォーマンストレーシングには、instrumentation.ts(グローバル)、Server Action でのラップ(ビジネスロジック)、middleware(リクエストレベル)の 3 層で使用することをお勧めします。📖 まとめ
- Lighthouse CI は CI/CD パイプラインでパフォーマンス、アクセシビリティ、ベストプラクティスを自動監査し、しきい値ベースのゲートキーピングで劣化したコードのマージを防止します。
@next/bundle-analyzerはパッケージサイズを可視化し、tree-shaking の失敗や大きすぎる依存関係を特定します。- Core Web Vitals RUM は
useReportWebVitalsを通じてリアルユーザーデータ(LCP/INP/CLS)を収集し、分析サービスにレポートします。 - PostHog と
@vercel/speed-insightsはすぐに使えるパフォーマンス分析ダッシュボードを提供します。 /instrumentation.tsは OpenTelemetry トレースを登録し、カスタムtrace()関数を使用してサーバーパフォーマンスを監視します。- 遅いルートを特定した後、優先度に基づいて最適化します:PPR 静的シェル、ISR キャッシュ、動的インポート、並列データ取得。
📝 練習問題
-
基本問題 (⭐):
lighthouserc.jsを設定してホームページとログインページを監査し、LCP < 2.5s、CLS < 0.1 のパフォーマンス予算を設定し、ローカルでlhci autorunを実行して設定が通ることを確認します。 -
応用問題 (⭐⭐): アプリケーションに
useReportWebVitalsを統合します:(1) データレポート用の API ルートを作成しデータベースに保存する。(2) ルートレイアウトにWebVitalsコンポーネントを追加する。(3) 本日の CWV 指標と遅いルート TOP 10 を表示する/performanceページを作成する。 -
発展問題 (⭐⭐⭐): 包括的なパフォーマンス監視ダッシュボードを実装します:(1) Bundle Analyzer を使用して現在のバンドルサイズを分析し、動的読み込みでファーストスクリーン JavaScript を 40% 以上削減する。(2) Lighthouse CI GitHub Actions ゲートを設定し、パフォーマンススコアが 5 ポイント以上低下した PR のマージをブロックする。(3) PostHog の
capture_performance自動トラッキングを統合し、7 日間の CWV トレンドチャートを作成する。