Documentation Next.js

はじめに

Next.js App Routerでは、Server ComponentsとClient Componentsの両方をデバッグする必要があります。この記事では、VSCode、Chrome DevTools、React DevToolsを使った効果的なデバッグ方法を解説します。

VSCodeでのデバッグ設定

launch.json設定

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Next.js: debug server-side",
      "type": "node-terminal",
      "request": "launch",
      "command": "npm run dev"
    },
    {
      "name": "Next.js: debug client-side",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:3000"
    },
    {
      "name": "Next.js: debug full stack",
      "type": "node-terminal",
      "request": "launch",
      "command": "npm run dev",
      "serverReadyAction": {
        "pattern": "- Local:.+(https?://.+)",
        "uriFormat": "%s",
        "action": "debugWithChrome"
      }
    },
    {
      "name": "Next.js: debug server-side (attach)",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

package.jsonのデバッグスクリプト

{
  "scripts": {
    "dev": "next dev",
    "dev:debug": "NODE_OPTIONS='--inspect' next dev",
    "dev:turbo": "next dev --turbo",
    "dev:turbo:debug": "NODE_OPTIONS='--inspect' next dev --turbo"
  }
}

tasks.json設定

// .vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Next.js: build",
      "type": "npm",
      "script": "build",
      "problemMatcher": ["$tsc"],
      "group": "build"
    },
    {
      "label": "Next.js: lint",
      "type": "npm",
      "script": "lint",
      "problemMatcher": ["$eslint-stylish"],
      "group": "test"
    }
  ]
}

Server Componentsのデバッグ

console.logでのデバッグ

// app/users/page.tsx
async function getUsers() {
  console.log('[Server] Fetching users...');

  const res = await fetch('https://api.example.com/users', {
    next: { revalidate: 60 },
  });

  console.log('[Server] Response status:', res.status);

  const data = await res.json();
  console.log('[Server] Users count:', data.length);

  return data;
}

export default async function UsersPage() {
  const users = await getUsers();

  // サーバーサイドのログはターミナルに出力される
  console.log('[Server] Rendering UsersPage');

  return (
    <div>
      <h1>Users</h1>
      {/* ... */}
    </div>
  );
}

Server Actionsのデバッグ

// app/actions.ts
'use server';

export async function createUser(formData: FormData) {
  console.log('[Action] createUser called');
  console.log('[Action] FormData:', Object.fromEntries(formData));

  try {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    console.log('[Action] Parsed data:', { name, email });

    // ブレークポイントをここに設定可能
    debugger;

    const result = await db.user.create({
      data: { name, email },
    });

    console.log('[Action] Created user:', result);

    return { success: true, user: result };
  } catch (error) {
    console.error('[Action] Error:', error);
    throw error;
  }
}

データフェッチのデバッグ

// lib/debug-fetch.ts
export async function debugFetch(
  url: string,
  options?: RequestInit
): Promise<Response> {
  const startTime = performance.now();

  console.log(`[Fetch] ${options?.method || 'GET'} ${url}`);

  if (options?.body) {
    console.log('[Fetch] Body:', options.body);
  }

  try {
    const response = await fetch(url, options);
    const duration = performance.now() - startTime;

    console.log(`[Fetch] ${response.status} ${response.statusText} (${duration.toFixed(2)}ms)`);

    // レスポンスヘッダーのログ
    console.log('[Fetch] Headers:', Object.fromEntries(response.headers));

    return response;
  } catch (error) {
    const duration = performance.now() - startTime;
    console.error(`[Fetch] Error after ${duration.toFixed(2)}ms:`, error);
    throw error;
  }
}

Client Componentsのデバッグ

React DevToolsの活用

// components/DebugComponent.tsx
'use client';

import { useState, useEffect, useDebugValue } from 'react';

// カスタムフックのデバッグ
function useDebugState<T>(initialValue: T, label: string) {
  const [value, setValue] = useState(initialValue);

  // React DevToolsで表示されるデバッグ値
  useDebugValue(`${label}: ${JSON.stringify(value)}`);

  useEffect(() => {
    console.log(`[${label}] State changed:`, value);
  }, [value, label]);

  return [value, setValue] as const;
}

export function DebugComponent() {
  const [count, setCount] = useDebugState(0, 'Counter');
  const [items, setItems] = useDebugState<string[]>([], 'Items');

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

useEffectのデバッグ

'use client';

import { useEffect, useRef } from 'react';

function useEffectDebugger(
  effect: React.EffectCallback,
  deps: React.DependencyList,
  name: string
) {
  const previousDeps = useRef<React.DependencyList>();

  useEffect(() => {
    if (previousDeps.current) {
      const changedDeps = deps.reduce<Record<number, { from: unknown; to: unknown }>>(
        (acc, dep, index) => {
          if (dep !== previousDeps.current?.[index]) {
            acc[index] = {
              from: previousDeps.current?.[index],
              to: dep,
            };
          }
          return acc;
        },
        {}
      );

      if (Object.keys(changedDeps).length > 0) {
        console.log(`[${name}] Dependencies changed:`, changedDeps);
      }
    } else {
      console.log(`[${name}] Initial render`);
    }

    previousDeps.current = deps;

    return effect();
  }, deps);
}

// 使用例
function MyComponent({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffectDebugger(
    () => {
      fetchUser(userId).then(setUser);
    },
    [userId],
    'FetchUser'
  );

  return <div>{/* ... */}</div>;
}

エラーハンドリングのデバッグ

error.tsxでのエラーキャッチ

// app/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // エラーログサービスに送信
    console.error('[Error Boundary]', {
      message: error.message,
      stack: error.stack,
      digest: error.digest,
    });
  }, [error]);

  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded">
      <h2 className="text-red-800 font-bold">エラーが発生しました</h2>

      {process.env.NODE_ENV === 'development' && (
        <details className="mt-2">
          <summary className="cursor-pointer text-red-600">
            エラー詳細
          </summary>
          <pre className="mt-2 p-2 bg-red-100 rounded text-sm overflow-auto">
            {error.stack}
          </pre>
        </details>
      )}

      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
      >
        再試行
      </button>
    </div>
  );
}

global-error.tsxでのルートエラー

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen flex items-center justify-center">
          <div className="text-center">
            <h2 className="text-2xl font-bold text-red-600">
              重大なエラーが発生しました
            </h2>
            <p className="mt-2 text-gray-600">
              ページを再読み込みしてください
            </p>
            <button
              onClick={reset}
              className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
            >
              再試行
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

ハイドレーションエラーのデバッグ

原因の特定

// components/HydrationDebug.tsx
'use client';

import { useEffect, useState } from 'react';

// ハイドレーション不一致の検出
export function HydrationDebug({ children }: { children: React.ReactNode }) {
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);
  }, []);

  if (process.env.NODE_ENV === 'development') {
    console.log('[Hydration] isClient:', isClient);
  }

  return <>{children}</>;
}

// 問題のあるコード例
function ProblematicComponent() {
  // ❌ サーバーとクライアントで異なる値
  const now = new Date().toLocaleString();

  return <p>現在時刻: {now}</p>;
}

// 修正版
function FixedComponent() {
  const [now, setNow] = useState<string>('');

  useEffect(() => {
    setNow(new Date().toLocaleString());
  }, []);

  return <p suppressHydrationWarning>現在時刻: {now || '読み込み中...'}</p>;
}

suppressHydrationWarningの使用

// components/ClientDate.tsx
'use client';

import { useState, useEffect } from 'react';

export function ClientDate({ date }: { date: string }) {
  const [formatted, setFormatted] = useState(date);

  useEffect(() => {
    setFormatted(new Date(date).toLocaleDateString('ja-JP'));
  }, [date]);

  return (
    <time dateTime={date} suppressHydrationWarning>
      {formatted}
    </time>
  );
}

ネットワークリクエストのデバッグ

カスタムロガー

// lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

const LOG_COLORS = {
  debug: '\x1b[36m', // Cyan
  info: '\x1b[32m',  // Green
  warn: '\x1b[33m',  // Yellow
  error: '\x1b[31m', // Red
  reset: '\x1b[0m',
};

class Logger {
  private prefix: string;

  constructor(prefix: string) {
    this.prefix = prefix;
  }

  private log(level: LogLevel, ...args: unknown[]) {
    if (process.env.NODE_ENV === 'production' && level === 'debug') {
      return;
    }

    const color = LOG_COLORS[level];
    const reset = LOG_COLORS.reset;
    const timestamp = new Date().toISOString();

    console[level](
      `${color}[${timestamp}] [${this.prefix}] [${level.toUpperCase()}]${reset}`,
      ...args
    );
  }

  debug(...args: unknown[]) {
    this.log('debug', ...args);
  }

  info(...args: unknown[]) {
    this.log('info', ...args);
  }

  warn(...args: unknown[]) {
    this.log('warn', ...args);
  }

  error(...args: unknown[]) {
    this.log('error', ...args);
  }
}

export const logger = new Logger('App');
export const apiLogger = new Logger('API');
export const dbLogger = new Logger('DB');

APIリクエストのトレース

// lib/api-client.ts
import { apiLogger } from './logger';

export async function apiClient<T>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  const requestId = Math.random().toString(36).substring(7);
  const url = `${process.env.API_URL}${endpoint}`;

  apiLogger.debug(`[${requestId}] Request: ${options?.method || 'GET'} ${url}`);

  const startTime = performance.now();

  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'X-Request-ID': requestId,
        ...options?.headers,
      },
    });

    const duration = performance.now() - startTime;

    apiLogger.info(
      `[${requestId}] Response: ${response.status} (${duration.toFixed(2)}ms)`
    );

    if (!response.ok) {
      const error = await response.text();
      apiLogger.error(`[${requestId}] Error response:`, error);
      throw new Error(`API Error: ${response.status}`);
    }

    const data = await response.json();
    apiLogger.debug(`[${requestId}] Response data:`, data);

    return data;
  } catch (error) {
    const duration = performance.now() - startTime;
    apiLogger.error(`[${requestId}] Failed after ${duration.toFixed(2)}ms:`, error);
    throw error;
  }
}

パフォーマンスデバッグ

React Profilerの使用

// components/ProfiledComponent.tsx
'use client';

import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.log(`[Profiler] ${id}:`, {
    phase,
    actualDuration: `${actualDuration.toFixed(2)}ms`,
    baseDuration: `${baseDuration.toFixed(2)}ms`,
    startTime,
    commitTime,
  });
};

export function ProfiledComponent({ children }: { children: React.ReactNode }) {
  if (process.env.NODE_ENV === 'production') {
    return <>{children}</>;
  }

  return (
    <Profiler id="App" onRender={onRenderCallback}>
      {children}
    </Profiler>
  );
}

Server Timingの計測

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const startTime = Date.now();

  const response = NextResponse.next();

  // Server-Timingヘッダーを追加
  const duration = Date.now() - startTime;
  response.headers.set('Server-Timing', `middleware;dur=${duration}`);

  return response;
}

よくある問題と解決策

問題原因解決策
ハイドレーションエラーサーバー/クライアントの不一致useEffect、suppressHydrationWarning
Server Actionsが動かない’use server’の欠落ファイル先頭に追加
環境変数が未定義NEXT_PUBLIC_の欠落クライアント用は接頭辞追加
キャッシュが更新されないrevalidateの欠落revalidatePath/revalidateTag
ブレークポイントが効かないソースマップの問題—inspect オプション確認

デバッグユーティリティ

// lib/debug-utils.ts
export function inspect<T>(value: T, label?: string): T {
  if (process.env.NODE_ENV === 'development') {
    console.log(label || 'Inspect:', value);
  }
  return value;
}

export function time<T>(fn: () => T, label: string): T {
  const start = performance.now();
  const result = fn();
  const duration = performance.now() - start;
  console.log(`[Timer] ${label}: ${duration.toFixed(2)}ms`);
  return result;
}

export async function timeAsync<T>(
  fn: () => Promise<T>,
  label: string
): Promise<T> {
  const start = performance.now();
  const result = await fn();
  const duration = performance.now() - start;
  console.log(`[Timer] ${label}: ${duration.toFixed(2)}ms`);
  return result;
}

// 使用例
const users = inspect(await getUsers(), 'Fetched users');
const data = await timeAsync(() => fetchData(), 'Data fetch');

まとめ

デバッグ対象ツール方法
Server ComponentsVSCode + Node.js—inspect、console.log
Client ComponentsChrome DevToolsReact DevTools、ブレークポイント
Server ActionsVSCodedebugger文、ログ出力
ハイドレーションReact DevToolsエラーメッセージ確認
パフォーマンスProfilerReact Profiler、Server-Timing

参考文献

円