コンテンツへスキップ
·5分で読めます

Next.js プロジェクトを大規模に構造化する:痛い目に遭って学んだこと

自らの重みで崩壊しない Next.js コードベースの整理方法について、苦労して得た教訓。機能ベースのアーキテクチャ、ルートグループ、サーバー/クライアントの境界、バレルファイルの罠、そして実際に使えるフォルダ構成。

シェア:X / TwitterLinkedIn

どんな Next.js プロジェクトも最初はきれいだ。数ページ、コンポーネントフォルダ、せいぜい lib/utils.ts 程度。生産性を感じる。整理されていると感じる。

そして6か月が過ぎる。3人の開発者が加わる。コンポーネントフォルダには140ファイル。誰かが utils.ts が800行になったからと utils2.ts を作成する。どのレイアウトがどのルートをラップしているか誰もわからない。ビルドに4分かかり、その理由もわからない。

このサイクルを何度も経験してきた。以下に書くのは、間違ったものを先にリリースして教訓の大半を学んだ後、現在実際にやっていることだ。

デフォルトの構造はすぐに崩壊する#

Next.js のドキュメントはプロジェクト構成を提案している。問題ない。個人ブログや小さなツールなら機能する。しかし、2人以上の開発者が同じコードベースに触れる瞬間、「型ベース」のアプローチにヒビが入り始める:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     ← the beginning of the end
  app/
    page.tsx
    dashboard/
      page.tsx

問題はこれが「間違っている」ということではない。機能に対して線形にスケールするが、理解度に対してはスケールしないということだ。新しい開発者が請求書機能に取り組む必要がある場合、components/hooks/utils/types/app/ を同時に開く必要がある。請求書機能が5つのディレクトリに散らばっている。「請求書機能とはこういうものだ」と示す単一の場所がない。

4人の開発者チームがまるまる1スプリントをかけてこの構造にリファクタリングし、これが「クリーン」な方法だと確信していたのを見た。3か月以内に彼らは再び混乱していた。フォルダが増えただけだった。

機能ベースのコロケーションが勝つ。毎回。#

実際のチームとの接触に耐えたパターンは機能ベースのコロケーションだ。考え方はシンプルで、ある機能に関連するすべてのものを一箇所にまとめる。

「すべてのコンポーネントを1つのフォルダに、すべてのフックを別のフォルダに」ではない。その逆だ。請求書機能のすべてのコンポーネント、フック、型、ユーティリティは features/invoicing/ の中に置く。認証に関するすべては features/auth/ の中に置く。

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

インポートに注目してほしい。すべて相対パスで、すべて機能内で完結している。このフックはグローバルな hooks/ フォルダや共有の utils/format.ts には手を伸ばさない。請求書番号のフォーマットは請求書機能の関心事であって、グローバルな関心事ではないため、請求書機能自体のフォーマットロジックを使用している。

直感に反する部分:これは若干の重複を意味する。 請求書機能にも format.ts があり、請求機能にも format.ts がある場合がある。それで構わない。14の機能が依存し、誰も安全に変更できない共有の formatters.ts よりも、実際にはその方が良い。

長い間これに抵抗していた。DRY は教義だった。しかし、共有ユーティリティファイルがコードベースで最も危険なファイルになるのを見てきた。ある機能のための「小さな修正」が他の3つを壊すファイルだ。制御された重複を伴うコロケーションは、強制的な共有よりも保守性が高い。

実際に使っているフォルダ構成#

完全なツリーがこちらだ。これは4〜8人のチームによる2つの本番アプリケーションで実戦テスト済みだ:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

いくつか注目すべき点:

  • app/ にはルーティングロジックのみを含める。 ページファイルは薄い。features/ からインポートして組み立てる。ページファイルは最大でも20〜40行にすべきだ。
  • features/ が実際のコードが存在する場所。 各機能は自己完結している。features/invoicing/ を削除しても他の何も壊れない(それをインポートしているページを除くが、それはまさに望ましい結合だ)。
  • shared/ はゴミ箱ではない。 厳密なサブカテゴリがある。これについては後述する。

ルートグループ:単なる整理以上のもの#

ルートグループ、つまり (括弧付き) フォルダは、App Router で最も活用されていない機能の一つだ。URL には一切影響しない。(dashboard)/invoices/page.tsx/dashboard/invoices ではなく /invoices でレンダリングされる。

しかし、真の力はレイアウトの分離にある。各ルートグループは独自の layout.tsx を持つ:

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

認証チェックはレイアウトレベルで一度だけ行われ、すべてのページで行うわけではない。公開ページはまったく異なるシェルを得る。認証ページにはレイアウトがなく、中央に配置されたカードのみだ。これはクリーンで、明示的で、ミスしにくい。

チームがミドルウェア内、個々のページ内、コンポーネント内、すべてに同時に認証チェックを入れているのを見てきた。1箇所を選ぶこと。ほとんどのアプリでは、ルートグループのレイアウトがその場所だ。

shared/ フォルダは utils/ ではない#

保守の悪夢を最速で作り出す方法は lib/utils.ts ファイルだ。最初は小さい。そして、明確な置き場所がないすべての関数が集まるコードベースのガラクタ入れになる。

私のルール:shared/ フォルダには正当な理由が必要。 何かが shared/ に入るのは以下の場合のみ:

  1. 3つ以上の機能で使用されている、かつ
  2. 本当にジェネリックである(ジェネリックな装いをしたビジネスロジックではない)

請求書の支払期日をフォーマットする日付フォーマット関数? それは features/invoicing/utils/ に入る。Date を受け取ってロケール文字列を返す日付フォーマット関数? それは shared/ に入れてよい。

shared/lib/ サブフォルダは、特にサードパーティの統合とインフラストラクチャ用だ。データベースクライアント、認証ライブラリ、決済プロバイダ。これらはアプリケーション全体が依存するが、単一の機能が所有するものではない。

typescript
// shared/lib/db.ts — これは shared に属する
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — これは shared に属さない
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

shared/utils/ に手を伸ばそうとして、ファイル名を helpers.ts より具体的に名付けられないなら、やめること。そのコードはおそらく機能の中に属している。

サーバーコンポーネント vs クライアントコンポーネント:意図的に線引きする#

パフォーマンスの問題を抱えるコードベースで常に見るパターンがある:

typescript
// これは合理的に見えるが間違いだ
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

ページに "use client" が付いている。これにより、インポートするすべてのもの、InvoiceTableInvoiceFilters、フック、すべての依存関係のすべての依存関係がクライアントバンドルに含まれる。JavaScript ゼロでサーバー上でレンダリングできた200行のテーブルが、ブラウザに送信されることになる。

修正方法は、"use client" を実際に必要なリーフコンポーネントまで押し下げることだ:

typescript
// app/(dashboard)/invoices/page.tsx — サーバーコンポーネント(ディレクティブなし)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* クライアントコンポーネント — インタラクティブなフィルターを持つ */}
      <InvoiceTable data={invoices} /> {/* サーバーコンポーネント — 行をレンダリングするだけ */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

私が従うルール:すべてのコンポーネントは useStateuseEffect、イベントハンドラ、またはブラウザ API が必要になるまでサーバーコンポーネントとする。 その時に初めて "use client" を追加し、そのコンポーネントをできるだけ小さくするよう努める。

ここで children パターンが最良の味方になる。クライアントコンポーネントはサーバーコンポーネントを children として受け取ることができる:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children はサーバーコンポーネントにできる */}
    </div>
  );
}

これが、インタラクティブ性が必要な部分を持ちながら、コンポーネントツリーの90%をサーバーに保つ方法だ。

バレルファイル:静かなビルドキラー#

以前はすべてのフォルダに index.ts を置いていた。クリーンなインポート、だろう?

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

こうすることでこのようにインポートできた:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

見た目は良い。問題はこうだ:バンドラーがそのインポートを見ると、InvoiceTable が何であるかを把握するためにバレルファイル全体を評価する必要がある。つまり、テーブルだけが必要だったとしても、InvoiceRowInvoiceFormInvoicePdf もロードされてしまう。

あるプロジェクトで、内部バレルファイルを削除したところ、開発時のモジュール数が11,000から3,500に減少した。開発サーバーのページロード時間は5〜10秒から2秒未満になった。ファーストロードの JavaScript は1メガバイト超から約200KBに減少した。

Vercel は node_modules からのサードパーティパッケージに対処するため next.config.tsoptimizePackageImports を構築した。しかし、自分のバレルファイルは修正されない。自分のコードについて、答えはもっとシンプルだ:使わないこと。

typescript
// こうする
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// こうしない
import { InvoiceTable } from "@/features/invoicing/components";

確かにインポートパスは長くなる。しかしビルドは速くなり、バンドルは小さくなり、開発サーバーが重くならない。毎回その交換を選ぶ。

唯一の例外:機能のパブリック API。features/invoicing/ が機能ルートレベルで単一の index.ts を公開し、他の機能がインポートできる2〜3個のものだけが含まれている場合、それは問題ない。境界として機能するのであって、便利なショートカットではない。しかし components/index.tshooks/index.ts のようなサブフォルダ内のバレルファイルは? 排除すべきだ。

薄いページ、厚い機能#

私が従う最も重要なアーキテクチャルールはこれだ:ページファイルは配線であり、ロジックではない。

app/ 内のページファイルがすべきことは3つだ:

  1. データをフェッチする(サーバーコンポーネントの場合)
  2. 機能コンポーネントをインポートする
  3. それらを組み立てる

それだけだ。ビジネスロジックなし。複雑な条件付きレンダリングなし。200行のJSXツリーなし。ページファイルが40行を超えてきたら、機能コードを間違った場所に置いている。

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

12行だ。ページは必要なデータをフェッチし、not-found のケースを処理し、それ以外のすべてを機能に委譲する。請求書詳細の UI が変更されたら、app/ ではなく features/invoicing/ を編集する。ルートが変更されたら、ページファイルを移動するだけで、機能コードは関知しない。

これが、15人の開発者が互いの邪魔をせずに同時に作業できるコードベースの実現方法だ。

不都合な真実#

悪いコードを防ぐフォルダ構成などない。ひどい抽象化だらけの美しく整理されたプロジェクトを見てきたし、コード自体が明確だったために作業しやすかった雑然としたフラットな構成も見てきた。

構造はツールであり、目標ではない。ここで説明した機能ベースのアプローチが機能するのは、大規模で最も重要なことに最適化しているからだ:このコードベースを一度も見たことがない開発者が、必要なものを見つけて、他のものを壊さずに変更できるか?

現在の構造がその質問に「はい」と答えられるなら、ブログ記事に言われたからといってリファクタリングしないこと。この記事であっても。

しかし、開発者がコードを書くよりもコードベースをナビゲートする時間の方が長いなら、「これはどこに置く?」がPRで繰り返される質問なら、請求機能への変更がなぜかダッシュボードを壊すなら、構造があなたと戦っている。そして上記のパターンが、毎回、それを修正してきた方法だ。

関連記事