🏗️

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つの方法を比較しました。

推奨する選び方

  1. 大規模プロジェクト(10万行以上)
    → モノレポ(Turborepo) または Multi-Zones

  2. 小規模プロジェクト(1万行以下)
    → 環境変数分岐

  3. 小〜中規模(5万行以下)+ コード共有が重要
    → Middleware書き換え(組織内に前例がある場合)
    → モノレポ(前例がない場合)


参考リンク


株式会社スプリックス IT戦略部・SPRIX Enginieering Lab

Discussion