Next.js 管理画面実践:RBAC 権限システムを設計から実装まで完全ガイド
エディタに表示された 23 個目の if (user.role === 'admin') を見つめています。
昨年引き継いだ管理画面プロジェクトでは、前任者が残した権限制御コードに苦しめられました。UserRole の判定が 20 以上のファイルに散在し、新しいロールを追加するたびに全体を検索して修正する必要がありました。あるとき修正漏れがあり、一般ユーザーが財務レポートを見られてしまいました。深夜に電話で呼び出され、バグ修正に追われたこともあります。
その頃、Next.js 管理画面の実装方法を徹底的に調べましたが、権限システムで悩んでいる開発者は多いことがわかりました。RBAC を使うべきだとは分かっていても、データベースはどう設計するか、ミドルウェアはどう書くか、動的メニューはどう生成するか、テーブルは Ant Design と shadcn/ui のどちらを選ぶか——標準的な答えはなく、試行錯誤は避けられません。
2 週間かけて権限システムをリファクタリングしたあと、ようやく安心して眠れるようになりました。本記事では、その経験を整理します。RBAC アーキテクチャ設計から Next.js 15 ミドルウェア実装、動的メニュー生成、テーブルコンポーネント選定まで、一式の方案をまとめています。
RBAC 権限モデル設計(なぜこの形なのか)
RBAC とは、なぜ広く使われるのか
RBAC は Role-Based Access Control(ロールベースアクセス制御)の略です。核となる考え方はとてもシンプル:ユーザー → ロール → 権限 → リソース。
「ユーザーに直接権限を付与すればいいのでは?」と思うかもしれません。可能ですが、面倒です。
想像してください。会社に CS が 5 人入社したとします。直接権限方式なら、注文閲覧・コメント返信・レポート出力……を一人ひとり設定する必要があります。RBAC なら「CS」ロールを作り、権限をロールに紐付け、新人にはロールを割り当てるだけ。設定は 1 回、以降は使い回せます。
さらに重要なのはメンテナンスコストです。「CS はレポート出力禁止にする」と PM が言ったら、RBAC ならロール権限を 1 回変更するだけで全 CS に反映されます。直接付与なら全員分を個別修正。1 件漏れれば本番事故です。
海外のエンタープライズ SaaS の 80% 以上が RBAC またはその派生を使っています。理由は実用的:柔軟性と保守性のバランスが取れているからです。ABAC(属性ベースアクセス制御)より単純で、直接ユーザー-権限バインドより柔軟です。
権限粒度をどう設計すれば疲れないか
権限粒度は難所です。粗すぎれば制御不足、細かすぎればメンテナンス地獄。
私の経験では 3 層に分けます:
ページ権限(ルーティングレベル)
- 最も基本。特定ページへアクセスできるか
- 例:
/admin/usersは管理者のみ - Next.js ミドルウェアで実装(後述)
モジュール権限(メニューレベル)
- サイドバーに何を表示するか
- 権限のないメニューを非表示にし、UX を向上
- フロントで権限に応じてメニュー設定を動的フィルタ
操作権限(ボタンレベル)
- 削除・編集など具体的な操作ボタン
- 例:「ユーザー削除」はスーパー管理者のみ
- 乱用注意。すべてのボタンに権限は不要
テーブルの列ごとに権限を付ける例も見ました。設定が複雑すぎ、性能も悪化。過剰設計しない——これが原則です。
権限命名は resource:action 形式を推奨:
user:create- ユーザー作成order:delete- 注文削除report:export- レポート出力
意味が一目でわかり、ソート・検索もしやすいです。
データベーステーブル設計
中核は 4 テーブル:ユーザー、ロール、権限、リソース。多対多は 2 つの関連テーブルで処理します。
// ユーザーテーブル
User {
id: string
name: string
email: string
// その他ユーザー情報
}
// ロールテーブル
Role {
id: string
name: string // "管理者"、"CS"、"運用"
code: string // "admin"、"service"、"operator"
description: string
}
// 権限テーブル
Permission {
id: string
name: string // "ユーザー作成"
code: string // "user:create"
resource: string // "user"
action: string // "create"
}
// リソーステーブル(任意、業務複雑度次第)
Resource {
id: string
name: string // "ユーザー管理"
code: string // "user"
type: string // "page" | "api" | "menu"
}
// ユーザー-ロール関連テーブル
UserRole {
userId: string
roleId: string
}
// ロール-権限関連テーブル
RolePermission {
roleId: string
permissionId: string
}
「User テーブルに roleId を直接持たせれば?」——答えは No。1 ユーザーが複数ロールを持つからです。
例:田中さんが「技術責任者」かつ「コンテンツ審査員」。両ロールの権限をマージする必要があります。中間テーブルなら自然に表現でき、クエリも JOIN するだけです。
組織構造(部門・ポジション)が必要なら Department / Position テーブルを追加。最初から全部作らず、必要に応じて拡張が正解。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>
}
一見問題なさそうですが:
- 全ページにコピペが必要
- 書き漏れでセキュリティホール
- ページ描画後に判定するためチラつき
- SSR 時のロジックが複雑化
Next.js ミドルウェアがこれを解決します。リクエストがページに到達する前に実行され、統一インターセプト・統一処理。性能がよく、コードも整理され、保守コストが下がります。
middleware.ts 完全実装
Next.js 15 のミドルウェアはプロジェクトルートの middleware.ts に書きます。ここでは NextAuth を使いますが、Clerk などに置き換え可能です。
// 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'], // 3 ロールすべて
'/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
return NextResponse.rewrite(new URL('/403', request.url))
}
// 5. 通過
return NextResponse.next()
}
// ミドルウェアマッチ設定
export const config = {
matcher: [
// 静的ファイルと API 以外(必要に応じ調整)
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
ポイント:
ルート権限マップ:定数オブジェクトで一覧化。新ルート追加もここだけ。
公開ルートホワイトリスト:ログイン・登録ページなどを別列挙。無限リダイレクトを防ぐ。
ログイン元の記録:loginUrl.searchParams.set('from', pathname) が重要。/admin/users で弾かれたユーザーは、ログイン後に同ページへ戻す UX 配慮。
権限不足の処理:redirect ではなく NextResponse.rewrite。URL は変えず 403 ページを表示。専用の無権限ページへリダイレクトする方法もあります。
フロントとバックの権限検証の連携
重要:ミドルウェアは第一防衛線。バックエンド API でも必ず再検証。
フロント権限判定の本質は UX 最適化。ブラウザのコードは改変可能。DevTools を開けば権限チェックは迂回できます。本当の防衛線はサーバー側です。
Next.js の Server Actions と API ルートでも権限を再検証します:
// 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 }
}
二重防御:
- フロントミドルウェア:素早いフィードバック。無権限ページを見せない
- バックエンド検証:本当のセキュリティ防衛。悪意あるリクエストを防ぐ
権限設定を共有モジュールに切り出し、フロント・バックで同じ設定を参照するチームも多いです。monorepo なら特に便利です。
性能最適化:権限情報をどこに置くか
毎リクエスト DB で権限取得?遅すぎます。
2 つの方案:
方案 1:権限情報を JWT にエンコード
// NextAuth callbacks
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
token.permissions = user.permissions // 権限リストも載せる
}
return token
}
}
メリット:ミドルウェアで DB 参照不要。デメリット:権限変更はトークン失効まで反映されない。権限変更が少ないシーン向け。
方案 2:Redis でユーザー権限をキャッシュ
権限変更が頻繁なら Redis にキャッシュし、ミドルウェアで参照。高速でリアルタイム性も高いが、依存が 1 層増えます。
私のプロジェクトは方案 1。トークン有効期限 1 時間。管理者が権限を変えたら再ログインを促せば十分。権限調整は高頻度ではありません。
動的メニュー生成と権限連動(UX の要)
メニュー設定データ構造
動的メニューの核心は ユーザー権限でメニュー項目をフィルタすること。まず完全なメニュー設定を持ち、現在ユーザーの権限で動的に絞り込みます。
メニュー設定の例:
// config/menu.ts
import { Home, Users, Settings, FileText } from 'lucide-react'
export interface MenuItem {
key: string
label: string
icon: React.ComponentType
path?: string
permission?: string // 必要な権限
children?: MenuItem[]
}
export const MENU_CONFIG: MenuItem[] = [
{
key: 'dashboard',
label: 'ダッシュボード',
icon: Home,
path: '/dashboard',
// permission 未設定 = 全ログインユーザー可
},
{
key: 'users',
label: 'ユーザー管理',
icon: Users,
permission: 'user:read',
children: [
{
key: 'users-list',
label: 'ユーザーリスト',
path: '/admin/users',
permission: 'user:read',
},
{
key: 'users-roles',
label: 'ロール管理',
path: '/admin/roles',
permission: 'role:read',
},
],
},
{
key: 'reports',
label: 'レポートセンター',
icon: FileText,
path: '/reports',
permission: 'report:read',
},
{
key: 'settings',
label: 'システム設定',
icon: Settings,
path: '/settings',
permission: 'system:config',
},
]
要点:
フラット vs ツリー:ここではツリー。ネスト関係が明確で、描画時に再帰処理。parentKey でフラット化する方法もあり、各有利弊。
permission は任意:未設定なら全ログインユーザーが閲覧可。「ダッシュボード」など基本ページは通常制限しない。
アイコンは文字列ではなくコンポーネント:lucide-react を直接 import。型安全で描画も簡単。
メニューフィルタアルゴリズム
設定ができたら核心:権限に応じてメニューをフィルタ。
落とし穴:親に権限がなく子に権限がある場合は?
例:ユーザーに user:read はないが role: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)
}
再帰フィルタでロジックが明確。メニュー項目は多くても数十件、性能も問題なし。
コンポーネントでの利用
メニューフィルタを React Hook に封装して再利用:
// hooks/usePermissionMenu.ts
'use client'
import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { filterMenuByPermissions } from '@/lib/menu'
import { MENU_CONFIG } from '@/config/menu'
export function usePermissionMenu() {
const { data: session } = useSession()
const filteredMenu = useMemo(() => {
if (!session?.user?.permissions) {
return []
}
return filterMenuByPermissions(MENU_CONFIG, session.user.permissions)
}, [session?.user?.permissions])
return filteredMenu
}
useMemo でキャッシュ。権限リストが変わらなければ再フィルタしません。
サイドバーでの利用:
// components/Sidebar.tsx
'use client'
import { usePermissionMenu } from '@/hooks/usePermissionMenu'
export function Sidebar() {
const menu = usePermissionMenu()
return (
<nav>
{menu.map((item) => (
<MenuItem key={item.key} item={item} />
))}
</nav>
)
}
すっきりした構成です。
ルートハイライトとパンくず
メニューフィルタに加え、現在ルートのハイライトとパンくずの 2 点。
ルートハイライトは pathname マッチ:
'use client'
import { usePathname } from 'next/navigation'
function MenuItem({ item }: { item: MenuItem }) {
const pathname = usePathname()
const isActive = item.path === pathname
return (
<Link
href={item.path || '#'}
className={isActive ? 'bg-blue-100 text-blue-600' : 'text-gray-700'}
>
<item.icon />
{item.label}
</Link>
)
}
パンくずは現在ルートに対応するメニューパスを探します:
// lib/menu.ts
export function getMenuPath(
menuItems: MenuItem[],
targetPath: string,
path: MenuItem[] = []
): MenuItem[] | null {
for (const item of menuItems) {
const currentPath = [...path, item]
if (item.path === targetPath) {
return currentPath
}
if (item.children) {
const result = getMenuPath(item.children, targetPath, currentPath)
if (result) return result
}
}
return null
}
再帰探索でルートから現在ノードまでのパスを返します。パンくずコンポーネントがそのまま使えます。
動的ルート(例:/admin/users/123)は動的パラメータ部分を除いてマッチ。業務に応じて調整してください。
データテーブルコンポーネント選定と実践
2026 年主流テーブル方案の比較
管理画面にテーブルは欠かせません。ユーザーリスト、注文リスト、ログリスト……どこにでもあります。適切なライブラリ選びで工数を大きく削れます。
主流方案を試した感触:
Ant Design Table
- メリット:機能充実、ドキュメント充実、中国語対応。ソート・フィルタ・ページネーション・展開行・固定列すべて揃う
- デメリット:スタイルカスタムが面倒、bundle サイズが大きい(antd 全体)、デザイン固定
- 向き:従来型管理画面、Ant Design に慣れたチーム
MUI DataGrid
- メリット:Material Design、機能強力、エンタープライズ機能(仮想スクロール、列並べ替え)
- デメリット:高度機能は有料(Pro)、学習曲線急、スタイル上書き複雑
- 向き:予算あり、大規模エンタープライズ機能が必要なプロジェクト
shadcn/ui + TanStack Table
- メリット:スタイル制約少、高カスタマイズ、TypeScript フレンドリー、性能優秀。コンポーネントを自分で制御、必要に応じて import
- デメリット:スタイルと UI を自分で書く初期投入
- 向き:モダンプロジェクト、柔軟性と性能重視、コードを書く意欲のあるチーム
React-Admin
- メリット:一体型、CRUD と権限統合、すぐ使える
- デメリット:フレームワーク縛り、柔軟性低、カスタム制限
- 向き:迅速プロトタイプ、標準 CRUD アプリ
最終的に shadcn/ui + TanStack Table を選びました。Tailwind CSS プロジェクトで shadcn/ui とシームレス、スタイル完全制御。TanStack Table の API 設計も優秀で、ロジックと UI 分離。UI ライブラリを変えてもロジックはそのまま。
shadcn/ui テーブル実装詳解
shadcn/ui の Data Table は完成コンポーネントではなく、組み立て方を教える方式。中核は TanStack Table、shadcn/ui が基本 Table UI を提供。
依存関係インストール:
npx shadcn@latest add table
npm install @tanstack/react-table
DataTable コンポーネントを作成(完全コードは記事先頭の例を参照)。
列定義だけで利用:
// app/admin/users/page.tsx
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/usePermission'
interface User {
id: string
name: string
email: string
role: string
}
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: '氏名',
},
{
accessorKey: 'email',
header: 'メール',
},
{
accessorKey: 'role',
header: 'ロール',
},
{
id: 'actions',
cell: ({ row }) => {
const user = row.original
const { hasPermission } = usePermission()
return (
<div className="flex gap-2">
{hasPermission('user:update') && (
<Button size="sm" variant="outline">
編集
</Button>
)}
{hasPermission('user:delete') && (
<Button size="sm" variant="destructive">
削除
</Button>
)}
</div>
)
},
},
]
export default function UsersPage() {
const users: User[] = [
{ id: '1', name: '田中', email: 'zhang@example.com', role: 'admin' },
{ id: '2', name: '佐藤', email: 'li@example.com', role: 'user' },
]
return (
<div className="container mx-auto py-10">
<DataTable columns={columns} data={users} />
</div>
)
}
actions 列で usePermission Hook によりボタン表示を制御。権限の異なるユーザーは異なる操作ボタンを見ます。
サーバーサイドページネーションとフィルタ
前述はクライアントページネーション。データ量が増えると破綻します。
本番はサーバーサイドページネーションが一般的。バックエンド API の例:
// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') || '0')
const size = parseInt(searchParams.get('size') || '10')
const [data, total] = await Promise.all([
db.user.findMany({
skip: page * size,
take: size,
}),
db.user.count(),
])
return Response.json({ data, total })
}
権限検証を忘れずに。ミドルウェアの章で説明した通りです。
テーブル権限制御のベストプラクティス
テーブル内権限制御は 2 層:
列権限:特定ロールのみ閲覧可の列(電話番号、身分証番号など)
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: '氏名',
},
// admin のみ機密列
...(hasPermission('user:view-sensitive')
? [
{
accessorKey: 'phone',
header: '電話番号',
},
]
: []),
]
操作権限:操作列ボタンを権限で表示
前述の例の通り、usePermission Hook でボタン描画を制御。
汎用権限判定 Hook を封装:
// hooks/usePermission.ts
'use client'
import { useSession } from 'next-auth/react'
export function usePermission() {
const { data: session } = useSession()
const hasPermission = (permission: string) => {
return session?.user?.permissions?.includes(permission) ?? false
}
const hasAnyPermission = (permissions: string[]) => {
return permissions.some((p) => hasPermission(p))
}
const hasAllPermissions = (permissions: string[]) => {
return permissions.every((p) => hasPermission(p))
}
return { hasPermission, hasAnyPermission, hasAllPermissions }
}
コンポーネントから使いやすく、ロジックも統一されます。
本番環境の注意点とベストプラクティス
よくある誤りとアンチパターン
踏んだ落とし穴をまとめます:
❌ 誤り 1:フロントだけで権限判定
最も危険。フロントコードはブラウザで実行、DevTools で自由に改変。
競合分析担当が一般ユーザーとして登録し、role: 'user' を role: 'admin' に書き換え、一晩中バックエンドデータを閲覧した例も。翌日 PM の顔色が変わりました。
✅ 正解:フロント権限は UX 最適化。バックエンド API で必ず再検証。機密操作は Server Actions または API ルートで権限チェック。
❌ 誤り 2:権限判定コードが everywhere
if (user.role === 'admin') が 20 ファイルに散在。新ロール追加で地獄。
✅ 正解:統一権限設定 + 統一判定関数。前述の usePermission Hook がその考え方。
❌ 誤り 3:権限設定のハードコード
// 反面教師
const ADMIN_USERS = ['admin@example.com', 'boss@example.com']
if (ADMIN_USERS.includes(user.email)) {
// 管理者ロジック
}
上司のメール変更でコード修正・再デプロイが必要。
✅ 正解:権限設定は DB に保存、動的クエリ。ロールと権限の関係も設定で管理、コードに書かない。
性能最適化戦略
権限システムを誤ると性能劣化。いくつかの技巧:
1. 権限情報を Token にエンコード
前述の通り、ユーザーロールと権限リストを JWT に載せ、毎リクエスト DB 参照を避ける。
2. メニューフィルタ結果のキャッシュ
メニューフィルタは再帰操作。毎描画計算しない。
usePermissionMenu Hook の useMemo がキャッシュ。権限リスト不変なら再フィルタなし。
3. ルートレベルコード分割
Next.js App Router はルートレベルコード分割を標準サポート。各ページ独立 chunk、アクセス時のみロード。
管理画面ページ数が多いと、分割なしでは初回ロードが遅くなります。
4. 不要な権限判定を減らす
細かすぎる権限判定もあります。読み取り専用ページにアクセスできれば基本権限あり、ページ内ボタンは増分権限(削除・編集)だけ判定すれば十分な場合も。
セキュリティチェックリスト
リリース前に確認:
✅ バックエンド API で権限検証
- すべての Server Actions に権限チェック
- すべての API ルートに権限チェック
- 機密操作に二次検証(ユーザー削除など)
✅ 権限昇格攻撃(Privilege Escalation)防止
- ユーザーは自分のロールを変更不可
- ユーザーは自分に権限追加不可
- 低権限ユーザーは高権限リソースにアクセス不可
✅ 監査ログ
- 重要操作を記録(ユーザー作成、データ削除、権限変更)
- 操作者・操作時刻・操作内容を含む
- ログ改ざん不可(追記のみ)
✅ セッション管理
- Token 適切な有効期限(1 時間推奨)
- 強制ログアウト(全セッション削除)対応
- パスワード変更後は旧 Token 無効
✅ 入力検証
- フロント・バック両方で入力検証
- Zod などでデータ構造定義
- SQL インジェクション防止(Prisma など ORM で自然に防御)
監視とアラート
権限システムのリリースは終わりではなく始まり。異常を早期発見:
監視指標:
- 403 エラー急増 → システム探索の可能性
- 特定ユーザーの短時間大量リクエスト → クローラーまたは攻撃
- 権限変更の頻発 → 設定の乱改
アラート戦略:
- スーパー管理者操作のリアルタイム通知
- 権限設定変更のメール通知
- 異常ログイン(异地・異常時間)の SMS 通知
Sentry、DataDog などで実装可能。
実際の教訓
オンライン障害事例——痛い教訓:
事例 1:メニュー権限とルート権限の不一致
メニュー設定で「財務レポート」を運用ロールに付与したが、ミドルウェアのルート権限に追加し忘れ。運用はメニューから入口を見えるが、クリックすると 403。1 週間ユーザーからの報告後に発覚。
教訓:権限設定は統一管理。メニュー権限とルート権限は同じ設定を使う。
事例 2:権限キャッシュによる反映遅延
権限を JWT にエンコード、有効期限 24 時間。運用がユーザー権限を取り消しても、翌日 Token 失効までアクセス継続。悪用が完了してから気づく。
教訓:機密操作は Token だけに依存せず、バックエンドで DB 再確認。または Redis で「取り消し権限」ブラックリスト。
事例 3:API 権限検証の漏れ
フロントページの権限制御は完璧だが、1 つの API に権限検証なし。Postman で直接呼び出し、フロント防御をすべて迂回。
教訓:バックエンド API が最後の防衛線。ミドルウェアまたはデコレータで統一処理。開発者が各 API に個別追加することを期待しない。
核心は一言:フロント権限は UX、バックエンド権限はセキュリティ。両方必要だが、バックエンドの方が重要。
結論
振り返ると、権限システムは高深な技術ではないが、きちんと作るのは容易ではありません。
本記事は RBAC 設計から Next.js 15 ミドルウェア実装、動的メニュー、テーブルコンポーネントまで、管理画面権限システムの全体をカバーしました。核心は 3 点:
- 設計で過剰にしない:必要に応じて拡張。最初から複雑すぎるモデルを作らない
- 実装は階層化:ミドルウェアでルート、メニューで権限フィルタ、ボタンは必要に応じて表示。役割分担
- セキュリティは二重防御:フロントで UX、バックエンドで安全。両方必要
管理画面開発中なら、こう始めることをおすすめします:
- まず RBAC 4 テーブル(ユーザー、ロール、権限、リソース)を構築
- Next.js ミドルウェアでルート保護、権限設定を定数に切り出し
- 動的メニューフィルタを実装し、Hook に封装
- テーブルは shadcn/ui + TanStack Table。柔軟性最高
2 週間のリファクタリングは大変でしたが、価値あり。今は新ロール追加が DB 設定だけ。PM が「監査員」ロール追加を依頼しても 10 分で完了。
権限システムが整えば、チーム全体の開発効率が上がります。安全事故のあとで慌てないうちに、今から整えましょう。
記事で触れたオープンソース HaloLight は参考になります。Next.js 15 + React 19 + TypeScript + RBAC の完全実装。コード品質も高く、学習価値大。
実装中に問題があれば、コメントで議論してください。権限システムの落とし穴はだいたい踏みました。できる限りお役に立てれば幸いです。
Next.js 管理画面 RBAC 権限システム実装フロー
Next.js 管理画面 RBAC 権限システムをゼロから構築する完全手順
⏱️ 目安時間: 120 分
- 1
ステップ1: ステップ 1:RBAC データベーステーブル設計
4 つのコアテーブルと 2 つの関連テーブルを作成:
**コアテーブル**:
• User テーブル:ユーザー基本情報
• Role テーブル:ロール定義(admin、operator、viewer など)
• Permission テーブル:権限定義(resource:action 形式、例 user:create)
• Resource テーブル(任意):リソース定義
**関連テーブル**:
• UserRole:ユーザー-ロール多対多
• RolePermission:ロール-権限多対多
**命名規則**:
権限は resource:action 形式。管理・検索が容易。
**拡張性**:
初期はシンプルに。後から Department(部門)と Position(ポジション)を追加して組織構造に対応。
Prisma など ORM で DB 構造を管理し、後から調整しやすくする。 - 2
ステップ2: ステップ 2:Next.js ミドルウェアルート保護
プロジェクトルートに middleware.ts を作成:
**ルート権限マップ設定**:
• ROUTE_PERMISSIONS 定数オブジェクトを作成
• 各ルートに必要なロールリストを定義
• PUBLIC_ROUTES ホワイトリスト(ログイン・登録ページなど)
**ミドルウェア核心ロジック**:
1. 公開ルートか確認、公開なら通す
2. getToken でユーザーセッション取得
3. 未ログインはログインページへ(元ページを記録)
4. ユーザーロールがルート要件と一致するか確認
5. 権限不足なら 403 ページ
**性能最適化**:
• ユーザー権限を JWT にエンコード
• 毎リクエスト DB 参照を避ける
• Token 有効期限を適切に(1 時間推奨)
**matcher 設定**:
静的ファイルと API を除外し、ページルートのみ検証。 - 3
ステップ3: ステップ 3:動的メニュー生成と権限フィルタ
メニュー設定とフィルタロジックを作成:
**メニュー設定構造**(config/menu.ts):
• ツリー構造でメニュー定義
• 各項目に key、label、icon、path、permission
• permission は任意。未設定なら全ログインユーザー可
**メニューフィルタアルゴリズム**(lib/menu.ts):
• filterMenuByPermissions 再帰関数を実装
• 親子メニュー権限関係を処理(親に権限なく子にあれば親を表示)
• フィルタ後のメニューツリーを返す
**カスタム Hook 封装**(hooks/usePermissionMenu.ts):
• useSession でユーザー権限取得
• useMemo でフィルタ結果キャッシュ
• 権限不変時は再計算回避
**ルートハイライトとパンくず**:
• usePathname で現在ルート取得
• getMenuPath でパンくずパス生成
• 動的ルートパラメータ対応 - 4
ステップ4: ステップ 4:shadcn/ui + TanStack Table 統合
再利用可能なデータテーブルコンポーネントを実装:
**依存関係インストール**:
• npx shadcn@latest add table
• npm install @tanstack/react-table
**DataTable コンポーネント作成**:
• TanStack Table の useReactTable Hook を使用
• ソート・ページネーション・フィルタ等の基本機能
• TypeScript 型安全サポート
**テーブル権限制御**:
• 列レベル:条件レンダリングで機密列表示制御
• 操作レベル:usePermission Hook でボタン表示制御
• hasPermission、hasAnyPermission、hasAllPermissions 等
**サーバーサイドページネーション**:
• API ルートで page と size パラメータ受信
• Prisma の skip と take でページネーション
• データリストと総数を返却
**権限検証**:
フロントテーブル権限は UX 最適化。バックエンド API で必ず再検証。 - 5
ステップ5: ステップ 5:バックエンド API と Server Actions 権限検証
バックエンドセキュリティ防衛線を確保:
**Server Actions 権限検証**:
• 各 Server Action 先頭で auth() 呼び出し
• ユーザーロールと権限を確認
• 権限不足ならエラーを throw
**API ルート権限検証**:
• getToken でユーザー情報取得
• リクエスト正当性を検証
• 機密操作に二次検証追加
**フロント・バック権限設定統一**:
• 権限設定を共有モジュールに切り出し
• フロント・バックで同じ設定を参照
• monorepo 構成で特に便利
**監査ログ記録**:
• 重要操作(作成、削除、権限変更)を記録
• 操作者・時刻・内容を含む
• ログは追記のみ、改ざん不可 - 6
ステップ6: ステップ 6:性能最適化とセキュリティ強化
本番環境最適化:
**性能最適化**:
• JWT にユーザー権限情報をエンコード
• useMemo でメニューフィルタ結果キャッシュ
• ルートレベルコード分割で初回ロード削減
• 不要な重複権限判定を削減
**セキュリティチェックリスト**:
• すべてのバックエンド API に権限検証
• 権限昇格攻撃防止
• Token 適切な有効期限設定
• フロント・バック両方で入力検証
• Zod でデータ構造定義
**監視とアラート**:
• 403 エラー数を監視
• ユーザー異常リクエストを監視
• スーパー管理者操作のリアルタイム通知
• 権限設定変更のメール通知
**よくある落とし穴の回避**:
• フロントだけで権限判定しない
• 権限設定をハードコードしない
• メニュー権限とルート権限を一致させる
• 機密操作を Token キャッシュだけに依存しない
FAQ
なぜ RBAC を使い、ユーザーに直接権限を付与しないのか?
• **一括管理**:CS 5 人増員でもロール割り当てだけ。5 回個別設定不要
• **一括更新**:ロール権限変更で該当ユーザー全員に即反映
• **拡張性**:1 ユーザーが複数ロール可。権限は自動マージ
• **ミス低減**:直接ユーザー-権限バインドは修正漏れでセキュリティリスク
海外 80% 超のエンタープライズ SaaS が RBAC を採用。柔軟性と保守性のバランスが最適。
Next.js ミドルウェアとコンポーネント内権限判定の違いは?
**ミドルウェア(推奨)**:
• ページ到達前に実行、統一インターセプト
• 性能良好。コンポーネント内より 60〜80% 高速
• コード集中管理、漏れにくい
• SSR 権限検証にも対応
**コンポーネント内判定**:
• ページ描画後に判定、チラつきの可能性
• 各ページに記述、漏れやすい
• コピペでメンテナンスコスト高
ただし:ミドルウェアは第一防衛線。バックエンド API で必ず再検証!
親メニューに権限がなく子メニューに権限がある場合は?
**表示ロジック**:
• 子メニューが 1 つでも見えれば親も表示
• ユーザーは権限のある子メニューにアクセス可能
**実装方式**:
再帰アルゴリズムで子を先にフィルタし、親の可視性を判定:
1. 子メニューを再帰処理
2. 現在項目の権限確認
3. 権限なくても可視子があれば残す
4. 権限も可視子もなければ除外
権限制御の厳密さと UX の両立。
shadcn/ui + TanStack Table と Ant Design Table はどう選ぶ?
**Ant Design Table を選ぶ場合**:
• チームが Ant Design に慣れている
• 迅速開発、すぐ使える機能が必要
• 従来型エンタープライズ管理画面
• 大きな bundle サイズを気にしない
**shadcn/ui + TanStack Table を選ぶ場合**:
• Tailwind CSS 使用
• 高度なスタイルカスタムが必要
• 柔軟性と性能重視
• コードを書く時間に投資できる
**データ比較**:
shadcn/ui + TanStack Table は 2024〜2026 年に 300% 超成長。モダン管理画面の第一選択に。
どちらも優秀。プロジェクト要件とチーム技術スタック次第。
フロントとバックエンド権限検証はどう連携する?
**フロント権限検証**(ミドルウェア + コンポーネント):
• 目的:UX 最適化、素早いフィードバック
• 位置:ミドルウェアでルート、コンポーネントでボタン
• 限界:DevTools で迂回可能。セキュリティ防衛線ではない
**バックエンド権限検証**(API + Server Actions):
• 目的:本当のセキュリティ防衛
• 位置:各 Server Action と API ルート
• 必須:機密操作は必ず検証。フロントに依存しない
**設定統一**:
• 権限設定を共有モジュールに
• フロント・バックで同じ設定参照
• ルール一致で抜け穴防止
**教訓**:フロント権限は完璧だが 1 API に検証漏れ。Postman で直接呼び出され全防御迂回。
権限情報は JWT に載せるか、毎回 DB を参照するか?
**方案 1:JWT エンコード(推奨)**:
• メリット:ミドルウェアで DB 参照不要、性能良好
• デメリット:権限変更は Token 失効まで反映されない
• 向き:権限変更が少ないシーン
• 推奨:Token 有効期限 1 時間
**方案 2:Redis キャッシュ**:
• メリット:リアルタイム性高、権限即反映
• デメリット:依存増、複雑度上昇
• 向き:権限変更が頻繁なシーン
**方案 3:DB クエリ**:
• メリット:100% リアルタイム
• デメリット:毎リクエスト DB、性能悪
• 非推奨:特殊業務要件以外
**ハイブリッド**:
JWT に権限 + Redis ブラックリスト(取り消し権限)。性能とリアルタイム性の両立。
権限システムリリース前に確認すべきセキュリティ項目は?
**バックエンド API 検証**:
• すべての Server Actions に権限チェック
• すべての API ルートに権限検証
• 機密操作に二次検証(ユーザー削除など)
**権限昇格攻撃防止**:
• ユーザーは自分のロール変更不可
• ユーザーは自分に権限追加不可
• 低権限ユーザーは高権限リソース不可
**監査と監視**:
• 重要操作ログ(改ざん不可)
• 403 エラー数監視
• スーパー管理者操作リアルタイム通知
• 権限設定変更メール通知
**セッション管理**:
• Token 適切な有効期限(1 時間推奨)
• 強制ログアウト対応
• パスワード変更後旧 Token 無効
**入力検証**:
• フロント・バック両方で検証
• Zod でデータ構造定義
• SQL インジェクション防止(Prisma 等 ORM)
覚えておくこと:フロント権限は UX、バックエンド権限はセキュリティ!
8分で読めます · 公開日: 2026年1月7日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js ファイルアップロード完全ガイド:S3/七牛云 署名付き URL 直伝の実践
Next.js で署名付き URL を使い S3/七牛云へ直伝する方法を解説。4MB 制限を突破し最大 5GB まで対応。本番向けコード例、性能最適化、セキュリティのベストプラクティス付き。
第 34 / 47 記事
次の記事
Next.js + Prisma 完全入門ガイド:設定から実践まで(接続リーク対策付き)
Next.js + Prisma の入門チュートリアル。環境構築、Schema 設計、CRUD 実践、ホットリロード時の接続リーク対策まで網羅し、Prisma ORM を素早く使いこなせるようにします。
第 36 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます