naoki.dev

記事検索

タイトル・本文・タグから検索します

ホームに戻る
フロントエンド

Next.js App Router のディレクトリ構成を「ルーティング」「ドメイン」「認証認可」で切り分ける

目次

App Router のディレクトリ構成で迷う原因の多くは、役割の異なるものを同じ軸で並べてしまうことにあります。整理の軸は3つだけです。ルーティングは app/、ドメインロジックは features/、認証は lib/auth/・認可は各データ操作の中。この線引きが決まると、新機能をどこに置くか毎回悩まずに済みます。

app/ はルーティング専用に薄く保つ

app/ の責務は URL とレイアウトの割り当てです。ここにドメインロジックを書き始めると、ページが肥大化し、同じ処理を別ページから使いたくなったときに詰みます。page.tsx は features のコンポーネントを組み立てるだけにします。

// app/posts/[slug]/page.tsx — ルーティングの担当
import { PostDetail } from "@/features/posts/components/post-detail";
import { getPost } from "@/features/posts/api/queries";
 
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
  const { slug } = await params;
  const post = await getPost(slug);
  return <PostDetail post={post} />;
};
export default Page;

app/ 内にコロケーションしたいコンポーネントは、_components/ のようにアンダースコア始まり(Private Folder)にしてルーティング対象から外します。レイアウトを分けたいだけなら Route Group((marketing)/ など)を使います。これは URL には含まれません。

ドメインロジックは features/ に寄せる

横断的に使うものは components/(shadcn/ui は components/ui/)、汎用ユーティリティやクライアント初期化は lib/、共有 hooks は hooks/、グローバル型は types/。そしてドメイン固有のロジックは features/<domain>/ にまとめます

feature の内部は役割で固定しておくと、どの feature でも同じ場所を見れば済みます。

features/posts/
├── components/      # UI(Server / Client)
├── api/
│   ├── queries.ts   # 取得系(Server Component から呼ぶ)
│   └── actions.ts   # Server Actions(作成・更新・削除)
├── hooks/           # フォームロジックなど
├── schemas.ts       # Zod スキーマ(バリデーションの単一ソース)
└── types.ts         # 型定義

Zod スキーマを schemas.ts に集約しておくと、フォーム(クライアント)と Server Action(サーバー)で同じスキーマを使い回せます。バリデーションの二重定義がなくなるのが効きます。

features はどの粒度で切るか

切り方に迷ったら「URL のまとまり」ではなく 「ドメイン(業務上の関心事)のまとまり」 で考えると安定します。ルーティングは app/ の担当なので、features/ はドメイン軸に統一するのが役割分担としてきれいです。

判断の目安はこのあたりです。

  • 基本は名詞(リソース)単位posts / comments / users のように、DB のテーブルやドメインオブジェクトと対応する粒度。CRUD 一式がそのフォルダに収まるイメージ。
  • 「一緒に変更されるか」で寄せる・分けるcomments が常に posts と一緒にしか触らないなら features/posts/ に含める。将来独立して育ちそうなら最初から分ける。修正のたびに何フォルダも行き来するなら、分割しすぎのサイン。
  • 粒度を混在させないfeatures/auth/(ドメイン)と features/dashboard/(画面単位)が混ざると、どこに置くか毎回迷う。画面固有のものは app/ 側の _components/ に置いて軸をぶらさない。

やりすぎ・やらなすぎの目安です。

粒度 判定
細かすぎ features/post-title/ ただの components/
粗すぎ features/main/ に全部入り 実質 features/ を使っていない
ちょうどいい 新機能を1フォルダ作れば大体そこで完結

コツは最初から完璧に切ろうとしないことです。まず app/ に素直に書き、同じロジックを2箇所目で使いたくなった/1ページのコードが肥大化してきた、というタイミングで features/ に抽出する。器を先に作るより、痛みが出てから切り出すほうが粒度を間違えにくい。個人開発〜小規模なら features/ を無理に作らず components/lib/hooks/ だけで十分回ります。

Server Actions のファイル名は規約であって固定ではない

actions.ts という名前をよく見ますが、これは慣習であって Next.js の規約ではありません。Next.js が特別扱いするのは "use server" ディレクティブであって、ファイル名は何でも構いません。判定はファイルの中身で行われ、先頭に "use server" があればそのファイルの export 関数がすべて Server Action になります。

// actions.ts でも mutations.ts でも動作は同じ
"use server";
 
export const createPost = async () => {
  /* ... */
};
export const deletePost = async () => {
  /* ... */
};

"use server" には2つの書き方があり、混同しやすいので整理します。

  • ファイルレベル(先頭に書く)… そのファイルの全 export 関数が Server Action。
  • 関数レベル(関数内の先頭に書く)… その関数だけが Server Action。Server Component 内に直接書くときに使う。
const Page = () => {
  const handleSubmit = async (formData: FormData) => {
    "use server";
    // この関数だけが Server Action
  };
  return <form action={handleSubmit}>{/* ... */}</form>;
};

命名の自由度は高いですが、チームで**「取得は queries.ts、更新は actions.ts」と役割で固定**しておくと、どの feature でも同じ場所を見れば済みます。自由だからこそ、あえて規約で縛る価値があります。

認証は入口で1回、認可はデータに触るたび毎回

認証(authN: 誰か)と認可(authZ: 何を許すか)は層が違うので、置き場所も分けます。方針は 認証基盤は lib/auth/ に集約、認可ロジックは各 feature の中 です。

lib/auth/
├── session.ts   # getUser() … 今誰か(null あり)
└── guards.ts     # requireUser() … いなければ弾く
 
features/posts/api/
├── queries.ts    # 取得。中で認可チェック
└── actions.ts    # 更新。中で認可チェック

まず「今誰か」を一元化します。

// lib/auth/session.ts
import { createClient } from "@/lib/supabase/server";
 
export const getUser = async () => {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  return user; // null の可能性あり
};
 
export const requireUser = async () => {
  const user = await getUser();
  if (!user) throw new Error("UNAUTHORIZED");
  return user; // 以降 non-null が保証される
};

そして重要なのが、認可(このユーザーがこのデータを触っていいか)は query / action の中で、データ操作とセットで書くことです。認可はデータそのものと切り離せないからです。

// features/posts/api/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/auth/session";
import { postSchema } from "../schemas";
 
export const updatePost = async (id: string, input: unknown) => {
  const user = await requireUser(); // 認証
 
  const parsed = postSchema.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
 
  const supabase = await createClient();
  const { error } = await supabase
    .from("posts")
    .update(parsed.data)
    .eq("id", id)
    .eq("user_id", user.id); // ← これが認可(自分の投稿だけ更新できる)
 
  if (error) return { error: error.message };
  revalidatePath("/posts");
  return { success: true };
};

考え方の軸はこうです。認証は「入口で1回」、認可は「データに触るたび毎回」。middleware でログイン必須ルートをまとめて弾くのは認証の話。「この投稿の所有者か」のような細かい認可はデータを見ないと判断できないので、必ず queries / actions 側で書きます。

middleware だけに頼ると、Server Action が直接叩かれたときに素通りする穴ができます。middleware でできるのは「ログインしているか」「このパスに入れるか」くらいの粗い判定までだと割り切るのが安全です。

RLS を最後の砦にする

Supabase を使うなら、アプリ側のチェックに加えて DB 側でも Row Level Security(RLS) を設定します。コードのチェック漏れがあっても DB が弾いてくれる、二重防御になります。

-- 自分の投稿だけ更新できる
create policy "update own posts"
  on posts for update
  using (auth.uid() = user_id);

まとめると、認証ヘルパー(requireUser())は lib/auth/ に集約、認可(所有権・権限チェック)は各 feature の queries / actions でデータ操作とセットに書く、DB の RLS で裏を取る。アプリ側チェックと RLS の3層で守るのが堅い構成です。

まとめ

  • app/ はルーティング専用に薄く保ち、ロジックは持たせない。
  • ドメインロジックは features/<domain>/ に、名詞(リソース)単位で寄せる。迷ったら app/ に書いて、痛みが出てから抽出する。
  • Server Actions のファイル名は自由。だからこそ queries.ts / actions.ts の役割で固定する。
  • 認証は lib/auth/ で入口に1回、認可はデータ操作とセットで毎回。Supabase なら RLS を最後の砦にする。

構成に唯一の正解はありませんが、「ルーティング・ドメイン・認可」を別の軸として扱うことだけ守れば、規模が大きくなっても破綻しにくくなります。

関連記事