はじめに
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行) - 型定義
分割の判断基準
- 500行を超えたら分割検討
- 責務が2つ以上あれば分割
- 再利用可能な部分は抽出
// 悪い例: 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つのこと:
-
TypeScript strict mode +
as any禁止 - 型安全性を最大化 - ESLint警告ゼロをCIで強制 - 品質低下を防止
- E2Eテストで主要フローを保護 - リグレッション防止
- コンポーネント分割ルール - 保守性向上
- Optimistic UI + コマンドパターン - UX改善 & 無限ループ防止
- セキュリティチェックリスト - 設計段階から組み込み
- ドキュメント駆動開発 - 設計意図を残す
これらは特別なことではなく、地道に続けることが重要です。一度に全部やろうとせず、できるところから始めてみてください。
実際のプロダクト
この記事で紹介した手法を適用して構築したサービスです:
| サービス | 概要 |
|---|---|
| Founders Direct Cockpit | 経営者・マネージャー向けタスク管理アプリ。アイゼンハワーマトリクス(4象限)でタスクを整理し、Google Calendar/Tasksと連携。OKR → ActionMap → Task の3層構造で戦略から実行までを一気通貫で管理。 |
| Founders Direct Workshop | 開発プロセスを学べる教材サイト。本記事の内容を含む38フェーズの開発ノウハウを公開中。Next.js + AI協働開発の実践例として参照可能。 |
参考リンク
この記事が参考になったら、いいねやストックをお願いします。質問があればコメントでお気軽にどうぞ。