Next.js 管理画面開発:RBAC 権限システム設計から実装までの完全ガイド

午前1時半。私はエディタに表示された23個目の if (user.role === 'admin') を前に呆然としていました。
昨年引き継いだ管理画面プロジェクトは、前任者が残した権限管理コードのせいで、メンテナンス不能な状態に陥っていました。UserRole の判定ロジックが20以上のファイルに散らばり、新しい役割(ロール)を追加するたびに、全ファイルを grep して修正しなければなりません。一度修正漏れがあり、一般ユーザーが財務レポートを見えてしまったことがありました。深夜に電話で叩き起こされ、冷や汗をかきがらバグ修正したあの感覚は、今でもトラウマです。
その時期、私は Next.js 管理画面の権限実装パターンを徹底的に調べました。RBAC(Role-Based Access Control)を使うべきなのは分かっていても、具体的にデータベースはどう設計する? ミドルウェアはどう書く? 動的メニューはどう生成する? テーブルコンポーネントは Ant Design と shadcn/ui どっちがいい? これらの問いに答えてくれる「正解」はどこにもなく、試行錯誤の連続でした。
2週間かけて権限システムをフルリファクタリングし、ようやく枕を高くして眠れるようになりました。この記事は、その時のリファクタリング経験を体系化したものです。RBAC アーキテクチャ設計から、Next.js 15 ミドルウェア実装、動的メニュー、テーブルコンポーネント選定まで、管理画面開発に必要な全ノウハウを詰め込みました。
RBAC 権限モデル設計(なぜこの形なのか)
なぜみんな RBAC を使うのか
RBAC(Role-Based Access Control:ロールベースアクセス制御)の核となる考え方は超シンプルです:ユーザー → ロール(役割) → 権限 → リソース。
「ユーザーに直接権限を与えちゃダメなの?」と思うかもしれません。ダメではありませんが、後で泣きを見ます。
想像してください。会社に新しいカスタマーサポート(CS)が5人入社しました。直接権限方式だと、一人ひとりに「注文閲覧」「コメント返信」「レポート出力…」と権限をポチポチ設定する必要があります。しかし RBAC なら、「CS」というロールを一つ作り、そこに権限を紐付けておけば、新人には「CSロール」を割り当てるだけで終わります。設定一回、効果は永続。
さらに重要なのは変更コストです。「CS は明日からレポート出力禁止ね」と言われたら? RBAC なら CS ロールの権限を修正するだけ。一括反映です。直接付与だったら、全 CS スタッフの権限を一人ずつ修正する必要があります。絶対ミスります。
権限粒度の「ちょうどいい」ライン
権限の粒度は悩みどころです。粗すぎれば制御できず、細かすぎれば管理地獄になります。
私の経験則では、以下の3層で考えると上手くいきます:
ページ権限(ルーティングレベル)
- 基礎中の基礎。指定したページにアクセスできるか。
- 例:
/admin/usersは管理者のみアクセス可。 - Next.js ミドルウェアで実装(後述)。
モジュール権限(メニューレベル)
- サイドバーにどのメニューを表示するか。
- 権限のないメニューは非表示にし、ユーザー体験を向上させる。
操作権限(ボタンレベル)
- 「削除」ボタンや「編集」ボタンの表示制御。
- 例:「ユーザー削除」ボタンは特権管理者のみ表示。
- 注意:すべてのボタンでやる必要はありません。重要な操作だけでOKです。
「テーブルの列ごとに権限をつける」みたいな極端な設計を見たことがありますが、実装が複雑すぎて現場が疲弊していました。「過剰設計しない」 が鉄則です。
権限の命名規則は resource:action 形式を推奨します:
user:create- ユーザー作成order:delete- 注文削除report:export- レポート出力
これならソートもしやすく、一目で意味がわかります。
データベース設計(スキーマ例)
基本は4つのテーブル(User, Role, Permission, Resource)と、多対多の中間テーブルです。
// User テーブル
User {
id: string
name: string
email: string
// ...
}
// Role テーブル
Role {
id: string
name: string // "管理者", "CS担当"
code: string // "admin", "service"
description: string
}
// Permission テーブル
Permission {
id: string
name: string // "ユーザー作成"
code: string // "user:create"
resource: string // "user"
action: string // "create"
}
// User-Role 中間テーブル(多対多)
UserRole {
userId: string
roleId: string
}
// Role-Permission 中間テーブル(多対多)
RolePermission {
roleId: string
permissionId: string
}「User テーブルに直接 roleId カラムを持たせればいいのでは?」という質問をよく受けますが、答えは No です。一人のユーザーが複数のロールを持つ可能性があるからです。
例えば、田中さんが「技術マネージャー」であり、かつ「採用担当」でもある場合、両方の権限を併せ持つ必要があります。中間テーブルを使えばこれを自然に表現でき、クエリも JOIN するだけですっきりします。
Prisma などの ORM を使えば、このリレーション管理は非常に簡単です。
Next.js ミドルウェアによるルート保護(実装の核心)
なぜミドルウェア必須なのか
初心者の頃、私は各ページコンポーネント内で権限チェックをしていました:
// ❌ ダメな例
export default function UsersPage() {
const { user } = useSession()
if (!user) redirect('/login')
if (user.role !== 'admin') return <div>権限がありません</div>
return <div>ユーザーリスト...</div>
}これの問題点は:
- 全ページにコピペが必要(DRY原則違反)。
- 書き漏れたら即セキュリティホール。
- ページが描画されてから判定するため、チラつき(Flash of Content)が起きる。
- SSR 時のロジックが複雑化する。
Next.js ミドルウェア はこれらを解決します。リクエストがページに到達する前にサーバー側で遮断するため、高速で安全、コードも一箇所にまとまります。
middleware.ts 完全実装コード
Next.js 15 の middleware.ts 実装例です(NextAuth 使用):
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
// ルート権限マップ
const ROUTE_PERMISSIONS = {
'/admin': ['admin'], // admin のみ
'/admin/users': ['admin', 'operator'], // admin と operator
'/dashboard': ['admin', 'operator', 'viewer'], // 全員OK
'/reports': ['admin'],
} as const
// 公開ルート(ログイン不要)
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. 公開ルートはスルー
if (PUBLIC_ROUTES.includes(pathname)) {
return NextResponse.next()
}
// 2. セッション取得
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})
// 3. 未ログインならログイン画面へ
if (!token) {
const loginUrl = new URL('/login', request.url)
// ログイン後のリダイレクト先を保持
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// 4. 権限チェック
const userRole = token.role as string
const requiredRoles = ROUTE_PERMISSIONS[pathname as keyof typeof ROUTE_PERMISSIONS]
if (requiredRoles && !requiredRoles.includes(userRole)) {
// 権限不足:403 ページへ(URLは維持したまま中身だけ書き換え)
return NextResponse.rewrite(new URL('/403', request.url))
}
// 5. 合格
return NextResponse.next()
}
// 適用範囲の設定
export const config = {
matcher: [
// API, 静的ファイル以外すべてに適用
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}ポイント解説:
ROUTE_PERMISSIONS: 権限設定をオブジェクトで一元管理。変更箇所はここだけ。PUBLIC_ROUTES: ログイン画面が保護されて無限リダイレクトループになるのを防ぎます。searchParams.set('from', pathname): ユーザーが/admin/usersにアクセスして弾かれた場合、ログイン後に再びそのページに戻してあげるUX上の配慮です。NextResponse.rewrite:redirectではなくrewriteを使うと、URL はそのままで中身だけ「403 Forbidden」ページを表示できます。プロっぽい挙動です。
「フロントの壁」と「バックの壁」
最重要ポイントです:**ミドルウェア等のフロントエンド制御は、あくまでUX(ユーザー体験)のためであり、セキュリティの最終防衛ラインではありません。**ブラウザ側のコードは改変可能です。
真のセキュリティは、バックエンド API(Server Actions)で担保します:
// app/actions/deleteUser.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deleteUser(userId: string) {
// バックエンドで再度ガッツリ検証
const session = await auth()
if (!session || session.user.role !== 'admin') {
throw new Error('権限がありません')
}
// 削除実行
await db.user.delete({ where: { id: userId } })
return { success: true }
}- フロントエンド(ミドルウェア):一般ユーザーが迷い込まないようにする看板(UX)
- バックエンド(API):鍵のかかったドア(Security)
この二段構えが必須です。
権限情報の持ち方:JWT vs DB
毎回 DB にアクセスして権限を取得するのは遅すぎます。
推奨:JWT に権限情報を焼く
// NextAuth callbacks
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
token.permissions = user.permissions // 権限リストも入れちゃう
}
return token
}
}こうすれば、ミドルウェアや API ではデコードされたトークンを見るだけで済み、DB アクセスが発生しません。欠点は「権限変更が即時反映されない(トークン再発行まで待つ)」ことですが、管理権限の変更頻度を考えれば許容範囲でしょう。即時性が必要なら Redis キャッシュを併用します。
動的メニュー生成と権限連動
サイドバーメニューも、権限に応じて出し分けたいですよね。
再帰フィルタリングの実装
まずメニュー構造を定義します。permission フィールドを持たせるのがミソです。
// config/menu.ts
export const MENU_CONFIG: MenuItem[] = [
{
key: 'dashboard',
label: 'ダッシュボード',
path: '/dashboard',
// permission なし = 全員OK
},
{
key: 'system',
label: 'システム管理',
permission: 'system:view', // 親メニューの権限
children: [
{
key: 'users',
label: 'ユーザー管理',
path: '/admin/users',
permission: 'user:read',
},
// ...
],
},
]そして、これをフィルタリングする関数を作ります。
課題:「親メニューの権限はないが、子メニューの権限はある」場合どうするか?
解決:子メニューが1つでも見えていれば、親も表示するようにします。
これを実現する再帰関数:
// lib/menu.ts
export function filterMenuByPermissions(
menuItems: MenuItem[],
userPermissions: string[]
): MenuItem[] {
return menuItems
.map((item) => {
// 子要素を先に再帰フィルタ
const filteredChildren = item.children
? filterMenuByPermissions(item.children, userPermissions)
: undefined
// 自身の権限チェック
const hasPermission =
!item.permission || userPermissions.includes(item.permission)
// 子要素が残っているか?
const hasVisibleChildren =
filteredChildren && filteredChildren.length > 0
// 自身も権限なく、子要素もないなら非表示
if (!hasPermission && !hasVisibleChildren) {
return null
}
// フィルタ済みの子要素をセットして返す
return { ...item, children: filteredChildren }
})
.filter((item): item is MenuItem => item !== null) // null除去
}これを useMemo でラップした Hook (usePermissionMenu) にして、サイドバーコンポーネントで使います。これで権限に応じたメニューツリーが自動生成されます。
データテーブル選定:2026年の最適解
管理画面といえばテーブル(グリッド)です。
比較:Ant Design vs MUI vs shadcn/ui
- Ant Design (antd):
- ◎ 機能全部入り、日本/中国で実績多数。
- △ バンドルサイズが巨大、デザイン変更(スタイル上書き)がつらい。
- MUI (Material UI):
- ◎ 堅牢、Material Design。
- △ 高度な機能(DataGrid Pro)が有料、独特なスタイリングシステム。
- shadcn/ui + TanStack Table (推奨):
- ◎ ヘッドレス UI(ロジックと見た目の分離)。Tailwind CSS 完全対応。軽量。
- △ 基本を自分で組む必要がある(コピー&ペースト)。
現代の Next.js 開発なら shadcn/ui 一択です。スタイルに縛られず、必要なロジックだけ TanStack Table が面倒を見てくれるアーキテクチャは、拡張性が段違いです。
実装のヒント:列レベルの権限制御
テーブルの「列」も権限で出し分けたいことがあります(例:個人情報が含まれる列)。
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: '氏名' },
// admin だけ電話番号が見える
...(hasPermission('user:view-sensitive')
? [{ accessorKey: 'phone', header: '電話番号' }]
: []),
{
id: 'actions',
cell: ({ row }) => {
// 操作ボタンの出し分け
return (
<div className="flex gap-2">
{hasPermission('user:edit') && <EditButton id={row.original.id} />}
{hasPermission('user:delete') && <DeleteButton id={row.original.id} />}
</div>
)
}
}
]このように、列定義の配列を生成する段階で権限チェックを挟めば、きれいに出し分けられます。
まとめ:失敗しないための鉄則
最後に、私が痛い目を見て学んだ「鉄則」をまとめます。
- フロントだけで守るな:ブラウザ上の権限チェックは「看板」に過ぎない。API の壁を必ず作る。
- 権限ロジックを一元化せよ:
if (role === 'admin')を散らかさない。usePermissionフックや中間件の設定ファイルに集約する。 - ハードコード禁止:
const ADMIN_EMAIL = 'boss@company.com'みたいなコードは絶対書かない。それは技術的負債になる。
RBAC システムは一度きちんと組んでしまえば、その後の開発が驚くほど楽になります。「誰が何を見れるか」をコードではなく設定として扱えるようになるからです。ぜひ、あなたの Next.js 管理画面にも導入してみてください。
Next.js RBAC 実装ステップ
Next.js 15 アプリケーションにロールベースアクセス制御(RBAC)を導入する具体的な手順
⏱️ Estimated time: 30 min
- 1
Step1: データベース設計
User, Role, Permission テーブルと、それらを繋ぐ中間テーブルを作成する。Prisma スキーマを定義し、マイグレーションを実行する。 - 2
Step2: ミドルウェア設定
middleware.ts を作成し、パスごとの必要権限(ROUTE_PERMISSIONS)を定義。NextAuth のトークンからユーザーロールを取得し、アクセス可否を判定するロジックを実装。 - 3
Step3: 権限管理フックの作成
フロントエンドコンポーネント用に usePermission フックを作成。セッションから権限リストを読み取り、hasPermission('user:create') のように簡単にチェックできるようにする。 - 4
Step4: API ルートの保護
Server Actions や API Route の先頭で必ずセッションチェックと権限チェックを入れる。フロントエンドのチェックを信頼せず、サーバーサイドで再検証する。 - 5
Step5: 動的メニューの実装
メニュー設定ファイルに permission フィールドを追加。再帰関数を使って、ユーザー権限に基づいて閲覧不可のメニュー項目をフィルタリングし、サイドバーに渡す。
5 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


コメント
GitHubアカウントでログインしてコメントできます