Next.jsで2つのアプリを1つのコードベースから提供する4つの方法:実践的トレードオフ比較
はじめに
Next.jsで開発していると、「ユーザー用画面」と「管理者用画面」のように、2つの異なるアプリケーションを同時に運用したいというケースに遭遇することがあります。
このとき、以下のような課題が生じます:
- 多くのコード(UIコンポーネント、APIクライアント、ユーティリティ等)を共有したい
- それぞれ独立してデプロイしたい
- 開発体験を損なわずに運用したい
この記事では、検討した4つの実装方法を比較し、実際にプロダクション環境で採用した方法とその選択理由、そしてそれぞれのトレードオフを詳しく解説します。
想定読者
- Next.js でマルチアプリケーション構成を検討している方
- モノレポ構成を導入すべきか迷っている方
- 実務での選択基準を知りたい方
この記事で学べること
- 4つの実装パターンとその特徴
- 各方法のメリット・デメリット
- プロジェクト規模別の推奨アプローチ
- 実際のコード例とディレクトリ構造
前提:プロジェクトの要件
以下の要件を満たす必要がありました:
- Next.js 15 App Router を使用
- ユーザー用画面と管理者用画面の2つのアプリケーション
- 多くのコンポーネント(UI、フォーム、APIクライアント等)を共有
- それぞれAWS環境上の異なるサーバー上にデプロイ
- 今回のケースではECS Fargateを利用
- CI/CDでデプロイ時に環境変数で切り替え
- 今回のケースではCodePipeline + CodeBuildを利用
方法1: モノレポ構成(Turborepo / Nx) - 最も一般的
モノレポツールを使って、複数のNext.jsアプリを管理する方法です。
ディレクトリ構造
workspace/
├── apps/
│ ├── user-app/ # ユーザー用Next.jsアプリ
│ │ ├── app/
│ │ ├── components/
│ │ ├── package.json
│ │ └── next.config.js
│ └── admin-app/ # 管理者用Next.jsアプリ
│ ├── app/
│ ├── components/
│ ├── package.json
│ └── next.config.js
├── packages/
│ ├── ui/ # 共通UIコンポーネント
│ ├── utils/ # 共通ユーティリティ
│ └── api-client/ # 共通APIクライアント
├── turbo.json # Turborepo設定
└── package.json # ルートpackage.json
実装例(Turborepo)
// turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
// apps/user-app/package.json
{
"name": "user-app",
"dependencies": {
"next": "15.0.0",
"@repo/ui": "*",
"@repo/utils": "*",
"@repo/api-client": "*"
}
}
向いているケース
- 大規模プロジェクト(10万行以上)
- チームが分かれている場合
- 将来的にアプリが増える可能性がある場合
- 各アプリが完全に独立している場合
方法2: Next.js Multi-Zones - Next.js公式
Next.jsの公式機能であるMulti-Zonesを使う方法です。複数のNext.jsアプリを1つのドメインで統合できます。
実装例
// user-app/next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/admin/:path*',
destination: 'http://admin-app:3001/admin/:path*'
}
];
}
};
// admin-app/next.config.js
module.exports = {
basePath: '/admin',
async rewrites() {
return [
{
source: '/:path((?!admin).*)*',
destination: 'http://user-app:3000/:path*'
}
];
}
};
向いているケース
- 完全に独立した2つのアプリケーション
- それぞれ異なるチームが開発
- 段階的な移行(既存アプリを残しつつ新アプリを追加)
方法3: 環境変数 + 条件分岐(ビルド時)
ビルド時に環境変数で不要なページを除外する方法です。
実装例
// next.config.js
const appType = process.env.APP_TYPE; // "user" or "admin"
module.exports = {
// ビルド時に不要なページを除外
pageExtensions:
appType === 'user'
? ['user.tsx', 'user.ts', 'tsx', 'ts']
: ['admin.tsx', 'admin.ts', 'tsx', 'ts']
};
// app/dashboard/page.user.tsx (ユーザー用)
export default function UserDashboard() {
return <div>ユーザー用ダッシュボード</div>;
}
// app/dashboard/page.admin.tsx (管理者用)
export default function AdminDashboard() {
return <div>管理者用ダッシュボード</div>;
}
向いているケース
- 小規模プロジェクト(1万行以下)
- 2つのアプリの差分が少ない
- 一時的な運用(将来的にモノレポに移行予定)
方法4: Middleware/Proxy によるパス書き換え ⭐️ 実践例
このプロジェクトで採用した方法です。Next.js の Middleware(Next.js 16 では proxy.ts)を使って、環境変数に応じてパスを書き換えます。
注意(2025年12月時点):
この記事では Next.js 15 のmiddleware.tsを使用していますが、
Next.js 16 ではproxy.tsに名称が変更されます。移行方法:
middleware.ts→proxy.tsにリネーム- エクスポート関数名を
middleware→proxyに変更- ランタイムが Edge → Node.js に変更
詳細は公式ドキュメントを参照してください。
なぜこの方法を選んだか
技術的理由
- コードを完全に共有したい
- ビルドを1回で済ませたい
- Docker イメージを1つにしたい
- デプロイ時に環境変数で切り替えたい
組織的理由
このプロジェクトでは、他のプロダクトでも同様のパターンを採用していました。
ただ、他のプロダクトではNext.jsを利用しておらず、方法4を選択したことの深い意味はありません。
重要な注意点:
この方法はあまり一般的ではないと思います。
- Next.js の公式ドキュメントには載っていない
- Stack Overflow や技術ブログでもほとんど見かけない
- 他の開発者が驚く可能性がある
新規プロジェクトでは、まずモノレポ(Turborepo)や Multi-Zones を検討することを推奨します。
ディレクトリ構造
app/
├── user/ # ユーザー用ページ
│ ├── login/
│ │ └── page.tsx # 実際のファイル: /app/user/login/page.tsx
│ ├── dashboard/
│ │ └── page.tsx # 実際のファイル: /app/user/dashboard/page.tsx
│ └── layout.tsx
├── admin/ # 管理者用ページ
│ ├── login/
│ │ └── page.tsx # 実際のファイル: /app/admin/login/page.tsx
│ ├── dashboard/
│ │ └── page.tsx # 実際のファイル: /app/admin/dashboard/page.tsx
│ └── layout.tsx
components/ # 共通コンポーネント
├── ui/ # shadcn/ui コンポーネント
└── ...
lib/
├── api/ # APIクライアント
└── utils/ # ユーティリティ
middleware.ts # パス書き換えロジック
パス書き換えの仕組み
環境変数 APP_TYPE の値に応じて、リクエストパスを書き換えます:
APP_TYPE="user" の場合:
リクエスト: / → 実際の処理: /user/
リクエスト: /dashboard → 実際の処理: /user/dashboard
リクエスト: /admin/xxx → エラー (500)
APP_TYPE="admin" の場合:
リクエスト: / → 実際の処理: /admin/
リクエスト: /dashboard → 実際の処理: /admin/dashboard
リクエスト: /user/xxx → エラー (500)
実装例
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
type AppType = {
key: string;
path: string;
};
const APP_TYPE_USER = "user";
const APP_TYPE_ADMIN = "admin";
const appTypes = [
{ key: APP_TYPE_USER, path: "/user" },
{ key: APP_TYPE_ADMIN, path: "/admin" }
] satisfies Array<AppType>;
const appType = process.env.APP_TYPE;
const currentType = appTypes.find((type) => type.key === appType);
export function middleware(request: NextRequest) {
// エラー: 未定義のアプリケーションタイプ
if (!currentType) {
return NextResponse.error();
}
const { pathname } = new URL(request.url);
// ヘルスチェックなどの除外パス
if (pathname.startsWith("/api/health")) {
return NextResponse.next();
}
// ログイン関連のページは認証チェックをスキップ
const isLoginRelatedPage = pathname.startsWith("/login");
// 現在のモードと異なるモードのページにアクセスしようとした場合はエラー
for (const type of appTypes) {
if (currentType.key !== type.key && pathname.startsWith(type.path)) {
return NextResponse.error();
}
// 認証チェック
if (!isLoginRelatedPage && !request.cookies.get("auth")) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
// ログイン済みユーザーがログインページにアクセスした場合はリダイレクト
if (isLoginRelatedPage && request.cookies.get("auth")) {
return NextResponse.redirect(new URL("/", request.url));
}
// パスを書き換え
const newUrl = new URL(request.url);
newUrl.pathname = `${currentType.path}${pathname}`;
return NextResponse.rewrite(newUrl.toString());
}
// 拡張子付きファイル(画像、CSS、JS等)は除外
export const config = {
matcher: "/((?!.*\\.).*)"
};
Docker Compose での環境変数設定
# docker-compose.yml
services:
app-user:
build: .
ports:
- "3000:3000"
environment:
APP_TYPE: "user" # ユーザー用
app-admin:
build: .
ports:
- "3001:3000"
environment:
APP_TYPE: "admin" # 管理者用
メリット
- ✅ コード共有が簡単: すべてのコンポーネント・ライブラリを完全に共有
- ✅ ビルドが1回: CI/CDでビルドプロセスが1つだけ
- ✅ Docker イメージが1つ: デプロイが効率的
- ✅ デプロイが柔軟: 環境変数を変えるだけで切り替え可能
- ✅ 依存関係の管理が楽: package.json が1つ
- ✅ 開発サーバーが1つ: ローカル開発時にポートが1つだけ
デメリット
- ❌ おそらく一般的でない: Next.js の公式パターンではない
- ❌ middleware が複雑: ルーティングロジックが読みづらい
- ❌ URLと実際のファイル位置が一致しない: デバッグが難しい
- ❌ バンドルサイズが最適化されない: 両方のコードが含まれる
- ❌ Next.js 16 で変更が必要:
proxy.tsへの移行が必要 - ❌ 拡張性が低い: アプリが3つ以上になると破綻する
向いているケース
- 小〜中規模プロジェクト(5万行以下)
- コード共有のメリットが大きい(共通コンポーネントが多い)
- 組織内で同様の運用パターンがある
- ビルド・デプロイの効率を優先したい
4つの方法の比較表
| 方法 | ビルド回数 | コード共有 | 複雑さ | スケール性 | 開発サーバー | Docker イメージ | 推奨規模 |
|---|---|---|---|---|---|---|---|
| モノレポ | 2回 | △ (packages/) | 高 | ◎ | 2つ | 2つ | 大規模 |
| Multi-Zones | 2回 | △ (packages/) | 高 | ◎ | 2つ | 2つ | 大規模 |
| 環境変数分岐 | 2回 | ◎ | 低 | △ | 1つ | 2つ | 小規模 |
| Middleware書き換え | 1回 | ◎ | 中 | △ | 1つ | 1つ | 小〜中規模 |
まとめ
Next.js でマルチアプリケーション構成を実現する4つの方法を比較しました。
推奨する選び方
-
大規模プロジェクト(10万行以上)
→ モノレポ(Turborepo) または Multi-Zones -
小規模プロジェクト(1万行以下)
→ 環境変数分岐 -
小〜中規模(5万行以下)+ コード共有が重要
→ Middleware書き換え(組織内に前例がある場合)
→ モノレポ(前例がない場合)
Discussion