言語を切り替える
テーマを切り替える

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 スタッフの権限を一人ずつ修正する必要があります。絶対ミスります。

エンタープライズ SaaS の RBAC 採用率

権限粒度の「ちょうどいい」ライン

権限の粒度は悩みどころです。粗すぎれば制御できず、細かすぎれば管理地獄になります。

私の経験則では、以下の3層で考えると上手くいきます:

  1. ページ権限(ルーティングレベル)

    • 基礎中の基礎。指定したページにアクセスできるか。
    • 例:/admin/users は管理者のみアクセス可。
    • Next.js ミドルウェアで実装(後述)。
  2. モジュール権限(メニューレベル)

    • サイドバーにどのメニューを表示するか。
    • 権限のないメニューは非表示にし、ユーザー体験を向上させる。
  3. 操作権限(ボタンレベル)

    • 「削除」ボタンや「編集」ボタンの表示制御。
    • 例:「ユーザー削除」ボタンは特権管理者のみ表示。
    • 注意:すべてのボタンでやる必要はありません。重要な操作だけで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>
}

これの問題点は:

  1. 全ページにコピペが必要(DRY原則違反)。
  2. 書き漏れたら即セキュリティホール。
  3. ページが描画されてから判定するため、チラつき(Flash of Content)が起きる。
  4. 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).*)',
  ],
}

ポイント解説

  1. ROUTE_PERMISSIONS: 権限設定をオブジェクトで一元管理。変更箇所はここだけ。
  2. PUBLIC_ROUTES: ログイン画面が保護されて無限リダイレクトループになるのを防ぎます。
  3. searchParams.set('from', pathname): ユーザーが /admin/users にアクセスして弾かれた場合、ログイン後に再びそのページに戻してあげるUX上の配慮です。
  4. 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 が面倒を見てくれるアーキテクチャは、拡張性が段違いです。

shadcn/ui 急成長

実装のヒント:列レベルの権限制御

テーブルの「列」も権限で出し分けたいことがあります(例:個人情報が含まれる列)。

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>
      )
    }
  }
]

このように、列定義の配列を生成する段階で権限チェックを挟めば、きれいに出し分けられます。

まとめ:失敗しないための鉄則

最後に、私が痛い目を見て学んだ「鉄則」をまとめます。

  1. フロントだけで守るな:ブラウザ上の権限チェックは「看板」に過ぎない。API の壁を必ず作る。
  2. 権限ロジックを一元化せよif (role === 'admin') を散らかさない。usePermission フックや中間件の設定ファイルに集約する。
  3. ハードコード禁止const ADMIN_EMAIL = 'boss@company.com' みたいなコードは絶対書かない。それは技術的負債になる。

RBAC システムは一度きちんと組んでしまえば、その後の開発が驚くほど楽になります。「誰が何を見れるか」をコードではなく設定として扱えるようになるからです。ぜひ、あなたの Next.js 管理画面にも導入してみてください。

Next.js RBAC 実装ステップ

Next.js 15 アプリケーションにロールベースアクセス制御(RBAC)を導入する具体的な手順

⏱️ Estimated time: 30 min

  1. 1

    Step1: データベース設計

    User, Role, Permission テーブルと、それらを繋ぐ中間テーブルを作成する。Prisma スキーマを定義し、マイグレーションを実行する。
  2. 2

    Step2: ミドルウェア設定

    middleware.ts を作成し、パスごとの必要権限(ROUTE_PERMISSIONS)を定義。NextAuth のトークンからユーザーロールを取得し、アクセス可否を判定するロジックを実装。
  3. 3

    Step3: 権限管理フックの作成

    フロントエンドコンポーネント用に usePermission フックを作成。セッションから権限リストを読み取り、hasPermission('user:create') のように簡単にチェックできるようにする。
  4. 4

    Step4: API ルートの保護

    Server Actions や API Route の先頭で必ずセッションチェックと権限チェックを入れる。フロントエンドのチェックを信頼せず、サーバーサイドで再検証する。
  5. 5

    Step5: 動的メニューの実装

    メニュー設定ファイルに permission フィールドを追加。再帰関数を使って、ユーザー権限に基づいて閲覧不可のメニュー項目をフィルタリングし、サイドバーに渡す。

5 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日

コメント

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

関連記事