1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Next.js 16 + React 19 + TypeScript 5.9 で構築したSaaSアプリを、技術負債ゼロの状態で本番リリースしました。

この記事では、開発中に実践した具体的な手法を共有します。これからプロダクトをリリースする方、既存プロジェクトの品質を改善したい方の参考になれば幸いです。


技術スタック

カテゴリ 技術 バージョン
フレームワーク Next.js (App Router) 16.0.8
UI React 19.2.1
言語 TypeScript 5.9.3 (strict mode)
DB Supabase PostgreSQL 17.6
テスト Playwright / Vitest 1.57.0 / 4.0.15
CI/CD GitHub Actions + Vercel -

1. TypeScript strict mode + as any 禁止

なぜ重要か

any 型は一時的に便利ですが、型安全性を破壊します。本番環境でのランタイムエラーの多くは、型チェックをすり抜けた any に起因します。

実践したこと

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true
  }
}
// ESLint で as any を検出
// eslint.config.js
export default [
  {
    rules: {
      "@typescript-eslint/no-explicit-any": "error"
    }
  }
];

効果

  • 型エラー検出: コンパイル時に問題を発見
  • IDE補完: 正確な補完でタイポを防止
  • リファクタリング安全性: 型が変更の影響範囲を教えてくれる

2. ESLint警告ゼロをCIで強制

なぜ重要か

「あとで直す」警告は永遠に直されません。警告をエラー扱いにすることで、品質の低下を防ぎます。

実践したこと

# .github/workflows/quality-gate.yml
- name: Lint
  run: npm run lint
  # ESLint は警告があると exit code 1 を返す設定に
// eslint.config.js
export default [
  {
    rules: {
      // 警告ではなくエラーに
      "no-console": "error",
      "no-unused-vars": "error"
    }
  }
];

ポイント

本番用に console.log を残さないためのルールも重要です:

// 開発時のみ許可するパターン
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn"

3. E2Eテストで主要フローを保護

なぜ重要か

ユニットテストだけでは、コンポーネント間の結合やAPIとの連携バグを見逃します。主要なユーザーフローは必ずE2Eテストで保護します。

実践したこと

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('ログイン → ダッシュボード表示', async ({ page }) => {
  await page.goto('/login');
  await page.getByRole('button', { name: 'Googleでログイン' }).click();

  // OAuth認証後のリダイレクト確認
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('タスク一覧')).toBeVisible();
});

test('タスク作成 → 保存確認', async ({ page }) => {
  await page.goto('/dashboard');
  await page.getByRole('button', { name: '新規タスク' }).click();
  await page.getByLabel('タイトル').fill('テストタスク');
  await page.getByRole('button', { name: '保存' }).click();

  // Optimistic UI の確認
  await expect(page.getByText('テストタスク')).toBeVisible();
});

テストカバレッジの考え方

テスト種別 対象 件数目安
E2E 主要ユーザーフロー 40-50件
ユニット ビジネスロジック・ユーティリティ 100-150件
型チェック 全ファイル -

4. コンポーネント分割ルール(500行制限)

なぜ重要か

巨大なコンポーネントは理解・テスト・修正が困難です。明確なルールを設けることで、自然と分割が進みます。

実践したこと

# 分割前
TodoBoard.tsx (1,200行)

# 分割後
TodoBoard/
├── index.tsx (86行) - コンテナ
├── QuadrantColumn.tsx (150行) - 4象限の列
├── TaskCard.tsx (120行) - タスクカード
├── HabitZone.tsx (100行) - 習慣エリア
└── types.ts (50行) - 型定義

分割の判断基準

  1. 500行を超えたら分割検討
  2. 責務が2つ以上あれば分割
  3. 再利用可能な部分は抽出
// 悪い例: 1つのコンポーネントに複数の責務
function TodoBoard() {
  // タスク取得ロジック
  // フィルタリングロジック
  // ドラッグ&ドロップロジック
  // レンダリング
}

// 良い例: 責務を分離
function TodoBoard() {
  const { tasks } = useTaskData();
  const { filtered } = useTaskFilters(tasks);

  return <TaskList tasks={filtered} />;
}

5. Optimistic UI + コマンドパターン

なぜ重要か

サーバー応答を待ってからUIを更新すると、ユーザーは「遅い」と感じます。即座にUIを更新し、エラー時にロールバックする方式で体感速度を向上させます。

落とし穴:Optimistic UI だけだと無限ループ

素朴に実装すると、以下の無限ループが発生します:

// NG: 無限ループになるパターン
function useTasks() {
  const [tasks, setTasks] = useState<Task[]>([]);

  // tasks が変わるたびに useEffect が発火
  useEffect(() => {
    saveToServer(tasks);  // 保存 → レスポンスで tasks 更新 → useEffect 発火 → 保存...
  }, [tasks]);

  const addTask = (task: Task) => {
    setTasks(prev => [...prev, task]);  // 状態更新 → useEffect 発火
  };
}

解決策:コマンドパターン

**コマンド(操作の意図)データ(現在の状態)**を分離します:

// types.ts
type CommandType = 'ADD_TASK' | 'UPDATE_TASK' | 'DELETE_TASK';

interface Command {
  type: CommandType;
  payload: Task | string;  // Task or taskId
  timestamp: number;
}

interface CommandPayload {
  command: Command;
}
// hooks/useTaskCommands.ts
export function useTaskCommands() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [pendingCommands, setPendingCommands] = useState<Command[]>([]);
  const tasksRef = useRef(tasks);
  tasksRef.current = tasks;

  // コマンドを実行(UIを即座に更新 + サーバーに送信)
  const executeCommand = useCallback(async (command: Command) => {
    // 1. Optimistic Update: UIを即座に更新
    setTasks(prev => applyCommand(prev, command));
    setPendingCommands(prev => [...prev, command]);

    try {
      // 2. サーバーにコマンドを送信
      await fetch('/api/tasks', {
        method: 'POST',
        body: JSON.stringify({ command }),
      });

      // 3. 成功: pendingから削除
      setPendingCommands(prev =>
        prev.filter(c => c.timestamp !== command.timestamp)
      );
    } catch (error) {
      // 4. 失敗: ロールバック
      setTasks(prev => rollbackCommand(prev, command));
      setPendingCommands(prev =>
        prev.filter(c => c.timestamp !== command.timestamp)
      );
      showUndoToast('保存に失敗しました');
    }
  }, []);

  // データ変更ではなく、コマンド発行
  const addTask = useCallback((task: Task) => {
    executeCommand({
      type: 'ADD_TASK',
      payload: task,
      timestamp: Date.now(),
    });
  }, [executeCommand]);

  return { tasks, pendingCommands, addTask };
}

// コマンドを適用してタスク配列を更新
function applyCommand(tasks: Task[], command: Command): Task[] {
  switch (command.type) {
    case 'ADD_TASK':
      return [...tasks, command.payload as Task];
    case 'UPDATE_TASK':
      const updated = command.payload as Task;
      return tasks.map(t => t.id === updated.id ? updated : t);
    case 'DELETE_TASK':
      return tasks.filter(t => t.id !== command.payload);
    default:
      return tasks;
  }
}

// ロールバック(逆操作を適用)
function rollbackCommand(tasks: Task[], command: Command): Task[] {
  switch (command.type) {
    case 'ADD_TASK':
      const added = command.payload as Task;
      return tasks.filter(t => t.id !== added.id);
    case 'DELETE_TASK':
      // 削除前の状態に戻すには元データが必要(省略)
      return tasks;
    default:
      return tasks;
  }
}

ポイント

項目 説明
useEffect でデータを監視しない コマンド発行時のみサーバーに送信
tasksRef で最新状態を参照 useCallback の依存配列に tasks を入れない
timestamp でコマンドを識別 重複実行・ロールバック時の特定に使用

同期状態の可視化

// SyncStatus コンポーネント
function SyncStatus({ pendingCount }: { pendingCount: number }) {
  if (pendingCount === 0) {
    return <span className="text-green-500">保存済み</span>;
  }
  return <span className="text-yellow-500">同期中... ({pendingCount})</span>;
}

6. セキュリティチェックリスト

なぜ重要か

セキュリティは「あとから追加」が困難です。設計段階から組み込むことで、手戻りを防ぎます。

実践したチェックリスト

項目 実装
認証 Supabase Auth + Google OAuth + PKCE
セッション HttpOnly, Secure, SameSite=Lax Cookie
CSRF トークン検証 + Origin チェック
XSS CSP Nonce + React のエスケープ
SQLi パラメータ化クエリ(Supabase SDK)
暗号化 AES-256-GCM(保存時)+ TLS(通信時)

CSP(Content Security Policy)の設定例

// proxy.ts (Next.js 16)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import crypto from 'crypto';

export function proxyRequest(request: NextRequest) {
  const nonce = crypto.randomBytes(16).toString('base64');

  const response = NextResponse.next();
  response.headers.set(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline';`
  );

  return response;
}

7. ドキュメント駆動開発

なぜ重要か

コードだけでは「なぜそう実装したか」が分かりません。設計意図を残すことで、将来の自分やチームメンバーを助けます。

実践したこと

docs/
├── FDC-CORE.md          # プロジェクト全体の指針
├── guides/
│   ├── DEVELOPMENT.md   # 開発ガイド(環境構築、コーディング規約)
│   ├── SECURITY.md      # セキュリティガイド
│   └── TESTING.md       # テスト方針
├── specs/
│   ├── API-SPEC.md      # API仕様
│   └── DB-SECURITY.md   # DB設計・セキュリティ
└── runbooks/
    └── PHASE20-PRODUCTION-RELEASE-CHECKLIST.md  # リリースチェックリスト

CLAUDE.md(AI向けガイド)

AI(Claude Code、GitHub Copilot等)と協働する場合、専用のガイドを用意すると効率が上がります:

# CLAUDE.md

## 禁止事項
- `as any` の使用禁止
- 絵文字の使用禁止(SVGアイコンを使用)
- 500行を超えるファイルの作成禁止

## 必読ドキュメント
1. docs/guides/DEVELOPMENT.md
2. docs/FDC-CORE.md

まとめ

技術負債ゼロでリリースするために実践した7つのこと:

  1. TypeScript strict mode + as any 禁止 - 型安全性を最大化
  2. ESLint警告ゼロをCIで強制 - 品質低下を防止
  3. E2Eテストで主要フローを保護 - リグレッション防止
  4. コンポーネント分割ルール - 保守性向上
  5. Optimistic UI + コマンドパターン - UX改善 & 無限ループ防止
  6. セキュリティチェックリスト - 設計段階から組み込み
  7. ドキュメント駆動開発 - 設計意図を残す

これらは特別なことではなく、地道に続けることが重要です。一度に全部やろうとせず、できるところから始めてみてください。


実際のプロダクト

この記事で紹介した手法を適用して構築したサービスです:

サービス 概要
Founders Direct Cockpit 経営者・マネージャー向けタスク管理アプリ。アイゼンハワーマトリクス(4象限)でタスクを整理し、Google Calendar/Tasksと連携。OKR → ActionMap → Task の3層構造で戦略から実行までを一気通貫で管理。
Founders Direct Workshop 開発プロセスを学べる教材サイト。本記事の内容を含む38フェーズの開発ノウハウを公開中。Next.js + AI協働開発の実践例として参照可能。

参考リンク


この記事が参考になったら、いいねやストックをお願いします。質問があればコメントでお気軽にどうぞ。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?