Next.js: パフォーマンス分析と監視

最終更新:2026-08-26

パフォーマンスは重要な属性です — 100 ミリ秒の遅延で 7% のユーザーを失う可能性があるインターネット時代において、パフォーマンス最適化は製品の競争力の中核指標です。

1. 学習内容



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 ファーストスクリーン読み込みが遅い

さらに悪いことに、彼女は次の質問に答えられませんでした:

(2) パフォーマンス監視システムのソリューション

Diana は包括的なパフォーマンス監視システムを確立しました:

TSX
// リアルユーザー監視 (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 監査を自動実行し、パフォーマンス予算しきい値を設定します。

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

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 統合

YAML
# .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 レポートの読み方

TEXT 📖 参照専用
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 モジュールのサイズを可視化し、大きすぎる依存関係を特定するのに役立ちます。

100%
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 の設定

JS
// 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) 操作分析

JSON
{
  "scripts": {
    "analyze": "ANALYZE=true npm run build",
    "analyze:server": "ANALYZE=true npm run build && npx serve .next/analyze"
  }
}

▶ サンプル: バンドル分析レポートの読み方

BASH
ANALYZE=true npm run build
TEXT 📖 参照専用
バンドルレポート — クライアント/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-fnsdayjs(45KB → 6KB) -39 KB
遅延読み込み React.lazy + Suspense -85 KB インタラクションライブラリ


5. Core Web Vitals リアルユーザー監視

Lighthouse はラボデータを提供しますが、実際のユーザー体験を反映できるのはリアルユーザー監視(RUM)だけです。

100%
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 レポートコンポーネント

TSX
// 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 ルート(データ保存)

TS
// 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) パフォーマンスダッシュボードクエリ

TS
// 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 データの読み方

TEXT 📖 参照専用
本日の 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) クライアント統合

TSX
// 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) ルートレイアウト統合

TSX
// 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 トレースの登録をサポートし、サーバーサイドのパフォーマンスと遅いルートを監視します。

TS
// 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 トラッキング

TS
// 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 での使用

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

▶ サンプル: サーバーコンポーネントトレーシング

💻 出力:

TEXT 📖 参照専用
Server Action が実行され、revalidatePath() を呼び出してページキャッシュを更新します。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
セクションに記載されている ▶ サンプル: サーバーコンポーネントトレーシング コンポーネントの UI をレンダリングします。


8. 遅いルートの特定と最適化

(1) 遅いルートのログミドルウェア

TS
// 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) パフォーマンス最適化チェックリスト

TSX
// 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. 完全な例: パフォーマンス監視ダッシュボード

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

❓ よくある質問

Q Lighthouse CI と PageSpeed Insights の違いは何ですか?
A Lighthouse CI は CI/CD パイプライン内で自動実行され、しきい値を設定してパフォーマンスの低下を防ぐことができます。PageSpeed Insights は Google が提供するオンラインツールで、CrUX のリアルユーザーデータに基づいています。両者は補完関係にあります:Lighthouse CI はデプロイ前のチェックに使用し、PageSpeed Insights はデプロイ後の検証に使用します。
Q Core Web Vitals の LCP、INP、CLS はそれぞれ何を表していますか?
A LCP(Largest Contentful Paint)は最大コンテンツ要素のレンダリング時間を測定し、2.5 秒未満が目標です。INP(Interaction to Next Paint)はユーザー操作に対するページの応答時間を測定し、200 ms 未満が目標です。CLS(Cumulative Layout Shift)はページレイアウトの安定性を測定し、0.1 未満が目標です。これら 3 つの指標は Google 検索ランキングに直接影響します。
Q Bundle Analyzer レポートの「gzip」と「parsed」の違いは何ですか?
A 「gzip」はサーバーで圧縮されて転送された後のファイルサイズ(実際のネットワーク転送サイズ)を指し、「parsed」はブラウザが解凍・解析した後のサイズ(JS エンジンの処理時間に影響します)を指します。通常は gzip サイズ(ネットワーク転送のボトルネック)と parsed サイズ(CPU 処理のボトルネック)の両方に注目する必要があります。Next.js はデフォルトで gzip 圧縮を有効にしています。
Q useReportWebVitals@vercel/speed-insights は競合しますか?
A いいえ、競合しません。useReportWebVitals は Next.js に組み込まれた Web Vitals のコールバックで、独自の分析サービスにデータをレポートするために使用できます。@vercel/speed-insights は Vercel が提供する有料サービスで、RUM データを自動収集して表示します。両方を同時に使用しても互いに干渉することはありません。
Q /instrumentation.tsmiddleware.ts の違いは何ですか?
A instrumentation.ts はアプリケーション起動時(サーバー起動時)に一度だけ実行され、OpenTelemetry や Sentry などのグローバルテレメトリの登録に使用されます。middleware.ts は各リクエストの前に実行され、リクエストのインターセプト、リダイレクト、ヘッダー注入に使用されます。パフォーマンストレーシングには、instrumentation.ts(グローバル)、Server Action でのラップ(ビジネスロジック)、middleware(リクエストレベル)の 3 層で使用することをお勧めします。
Q 遅いルートの最適化はどこから始めればよいですか?
A 推奨される最適化手順は次のとおりです:(1) Bundle Analyzer を使用して大きなバンドルを特定し分割します。(2) Lighthouse CI でパフォーマンスゲートキーパーを設定します。(3) RUM データを収集して最も遅い 5% のユーザーを特定します。(4) 最も遅くリクエスト量の多いルートから最適化を開始します(PPR、ISR、動的インポートを使用します)。(5) データベースクエリを最適化します(N+1 クエリ、不足しているインデックス)。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): lighthouserc.js を設定してホームページとログインページを監査し、LCP < 2.5s、CLS < 0.1 のパフォーマンス予算を設定し、ローカルで lhci autorun を実行して設定が通ることを確認します。

  2. 応用問題 (⭐⭐): アプリケーションに useReportWebVitals を統合します:(1) データレポート用の API ルートを作成しデータベースに保存する。(2) ルートレイアウトに WebVitals コンポーネントを追加する。(3) 本日の CWV 指標と遅いルート TOP 10 を表示する /performance ページを作成する。

  3. 発展問題 (⭐⭐⭐): 包括的なパフォーマンス監視ダッシュボードを実装します:(1) Bundle Analyzer を使用して現在のバンドルサイズを分析し、動的読み込みでファーストスクリーン JavaScript を 40% 以上削減する。(2) Lighthouse CI GitHub Actions ゲートを設定し、パフォーマンススコアが 5 ポイント以上低下した PR のマージをブロックする。(3) PostHog の capture_performance 自動トラッキングを統合し、7 日間の CWV トレンドチャートを作成する。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%