はじめに
私が今関わるプロジェクトではNext.jsを採用し、AIを活用したWebアプリケーションを開発しています。
しかし開発が進み、機能やチームメンバーが増えるにつれてコードの複雑性が増大。
最終的には npm run dev コマンドでローカル開発環境が起動しなくなる(メモリ不足・タイムアウト) という事態に陥りました。
なぜここまで開発環境が悪化してしまったのか。その原因と、私たちが痛感したアーキテクチャ上の反省点を共有します。
-- 構成 --
- フレームワーク: Next.js 14 (App Router)
- 言語: TypeScript, Node.js v20
- UI: TailwindCSS, Shadcn/ui
- Form & Validation: React Hook Form, Zod
- データベース: PostgreSQL, Prisma ORM
- 認証: NextAuth.js
- テスト: Jest
1. Atomicデザイン採用の弊害
アトミックデザインはコンポーネントを小さな単位に分割し、再利用性を高める設計手法です。
プロジェクトの走り出し当初は綺麗に設計されたコンポーネントが配置されていました。
しかし、実際には過度に細分化されたコンポーネントが増え、管理が難しくなりました。
機能単位でのpageコンポーネントのコードが増え、最適なコンポーネント分割がされなくなりました。
さらに、アトミックデザインで詳細にコンポーネントが分割される想定だったため、フックを活用したロジックとUI層の分離をしていませんでした。
メンバーが増え開発が進むにつれ、コンポーネント分割されなくなり、pageコンポーネントにソースコードが集約され、さらにはロジック層とUI層の分離がされなくなりました。
機能単位でのコロケーション構成にし、UIとロジックを分離するContainer/Presentationalパターンを採用すべきでした。
【解説】ディレクトリ迷路と肥大化したPage
本来はAtomic Designの階層に分かれるはずが、探すのが面倒になり、結局 app/features/xxx/page.tsx にすべてを書いてしまう現象です。
理想
src/components/
├── atoms/
│ └── Button/
├── molecules/
│ └── SearchForm/
└── organisms/
└── UserCard/
現実(「神」コンポーネント爆誕)
src/components/
└── pages/
└── users.tsx ← ここにUI、ロジック、スタイル、全てが集約
かろうじてatomsだけ再利用される。
※参考記事
2. API RoutesとServer Actionsの混在
Next.jsではAPI RoutesとServer Actionsの両方を使用できますが、後述するPrismaでのDBアクセス層がそれぞれに混在されており、
どのような意図で使い分けているのかチーム内で統一されていませんでした。
プロジェクト当初は Server Actionsが出始めだったため(Next.js 14)、試験的に 各機能単位でのpage.tsxから呼び出すデータ取得関数としてそれぞれ実装されていました。
しかし、機能が増えるにつれ、Server Actionsは使われなくなり、API RoutesでのGETをクライアントコンポーネントからFetchで呼び出すコードが混在し、コードの一貫性が失われました。
【コード例】同じ「ユーザー取得」なのにバラバラな実装
初期実装されたServer Actionsが無視され、検索機能のためにAPI Routeが勝手に生やされた例です。
1. 初期実装:ユーザー一覧(Server Actions派) サーバーコンポーネントから直接DB取得関数を呼ぶ、Next.js推奨の形で作られました。
// 📁 actions/user.ts
"use server";
import { prisma } from "@/lib/prisma";
// ユーザー一覧取得(初期表示用)
export async function getUsers() {
// Prismaを直接呼んでいる
return await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, name: true, email: true } // 必要なフィールドを指定
});
}
// 📁 app/users/page.tsx (Server Component)
import { getUsers } from "@/actions/user";
export default async function UserListPage() {
const users = await getUsers(); // ここではServer Actionsを使用
return (
<div>
<h1>ユーザー一覧</h1>
{/* ... */}
</div>
);
}
2. 追加実装:ユーザー検索(Client Fetch / API Route派) 後から参画したメンバーが検索機能を実装。既存の getUsers を拡張したり再利用するのではなく、使い慣れた fetch を使うために専用のAPI Routeを作ってしまいました。
// 📁 app/api/users/search/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// 検索用API(actions/user.tsと同じようなロジックを再実装...)
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q");
// ❌ ロジックの重複!
// selectするフィールドも微妙に actions/user.ts とズレていたりする
const users = await prisma.user.findMany({
where: { name: { contains: q } },
select: { id: true, name: true } // emailが含まれていない等の不整合が発生
});
return NextResponse.json(users);
}
// 📁 components/molecules/searchForm.tsx (Client Component)
"use client";
export default function UserSearch() {
// ...
const handleSearch = async (query) => {
// 既存のServer Actionsを使わず、わざわざAPIをfetchする
const res = await fetch(`/api/users/search?q=${query}`);
const data = await res.json();
setUsers(data);
};
// ...
}
3. バックエンド層のベタ書き
いわゆるクリーンアーキテクチャやレイヤードアーキテクチャのような層構造を意識した設計がされておらず、API RoutesやServer Actions内に直接PrismaのDBアクセスコードがベタ書きされていました。
そのため、ビジネスロジックとデータベースアクセスロジックが混在し、コードの可読性と保守性が低下しました。
【解説】層のないアーキテクチャ
「Controller (Handler)」の中に全てが詰め込まれており、Service層やRepository層が存在しません。
[API Route / Server Action]
│
├─ 入力バリデーション
├─ ビジネスロジック (if文の嵐)
└─ DBアクセス (prisma.findMany...)
(すべてが1つの関数内にある)
4. 過度なテストコード網羅
プロジェクトリリース当初は、page.tsx, API Routes, Server Actions, さらにクライアントコンポーネントすべてにレンダリングテストが書かれていました。
機能が追加されたり、変更が加わるたびにテストコードの修正が間に合わず、スキップされることが増えました。
その結果、破綻したテストコードが大量にできてしまいました。
npm run devサーバーが重くなってしまう。
これらの問題が重なり、バンドルサイズが肥大化しnpm run devコマンドでのローカル環境起動ができなくなり、開発効率が大幅に低下しました。
現在
これらの技術的負債を抱え、まずはAPI Routes, Server Actions, Prismaで作成されたバックエンドを分離する作業をしている最中です。
フロントエンドをどのようにすべきかはまだ確定していませんが、以下のような案がでています。
- BFFとしてNext.jsを採用
- ReactでSPA化
- Svelte kitのようなシンプルで破綻しづらいフレームワークを採用する
私なりの結論
中規模以上のチーム開発においてはNext.jsにはバックエンドを実装すべきではない。(プロジェクトの規模やフェーズにもよるのでNext.jsでどこまで実装するかはケースバイケース)
FastAPIやGo、RailsなどでRest APIサーバーを分離しておくべき。
TypeScriptで統一したいならHonoがよさそう?
Next.jsはBFFとしての役割に徹する。
この記事に大変共感しました。↓
そもそもNext.jsを使わないという選択肢もあります。
最後に
Next.jsは今なにかとHotなフレームワークです。
カスタマイズ性が高く、うまく使いこなせばモダンなWebアプリケーションをスピード感を持って開発できます。
一方その自由度が仇となり、チーム内で認識を共有しないと秩序のないコードベースになってしまいがちです。
RSCやNode.js自体にも脆弱性が見つかっています。
そもそもバックエンドをTypeScriptで書くべきではないという意見もあります。
正解は一つではないですが、この失敗談が誰かの技術選定に役立てば幸いです。