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


