レビュー済み·難易度: 初級·更新: 2026-04-18

Next.js で権限不足(unauthorized)時の専用ページを表示する

認証済みだが権限が不足しているユーザーに unauthorized ページを表示し、適切なメッセージと導線を提供するパターン。Next.js の unauthorized() 関数と not-found の使い分けを示す例。

nextjsauthenticationrouting

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router の Server Component と認証フローの基本を理解していること

概要

Next.js 15 の unauthorized() 関数を使い、認証済みだが権限が不足しているユーザーに専用の unauthorized ページを表示する。not-found() との使い分け、unauthorized.tsx ファイルの配置、ロールベースのアクセス制御パターンを示す。

インストール

# 追加インストールは不要

実装

unauthorized.tsx(カスタム unauthorized ページ)

// app/unauthorized.tsx
import Link from "next/link";

export default function UnauthorizedPage() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center px-4 text-center">
      <div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-red-50">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          className="h-10 w-10 text-red-400"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={1.5}
            d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
          />
        </svg>
      </div>

      <h1 className="mb-2 text-2xl font-bold text-gray-900">アクセス権限がありません</h1>
      <p className="mb-8 max-w-sm text-sm text-gray-500">
        このページを表示する権限がありません。管理者にお問い合わせいただくか、別のアカウントでサインインしてください。
      </p>

      <div className="flex gap-3">
        <Link
          href="/"
          className="rounded-lg border border-gray-300 px-5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
        >
          トップに戻る
        </Link>
        <Link
          href="/login"
          className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
        >
          別のアカウントでログイン
        </Link>
      </div>
    </main>
  );
}

ロールチェックして unauthorized を返すページ

// app/admin/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { unauthorized } from "next/navigation";

type UserRole = "admin" | "editor" | "viewer";

async function getCurrentUserRole(): Promise<UserRole | null> {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session_id");

  if (!sessionId) return null;

  // 実際のアプリでは DB からセッションとロールを取得する
  // ここではサンプルのため固定値を返す
  return "viewer";
}

export default async function AdminPage() {
  const role = await getCurrentUserRole();

  // 未認証 → ログインページへ redirect
  if (!role) {
    redirect("/login");
  }

  // 認証済みだが権限不足 → unauthorized ページを表示
  if (role !== "admin") {
    unauthorized();
  }

  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-4 text-2xl font-bold">管理者ダッシュボード</h1>
      <p className="text-gray-600">管理者のみ表示されるページです。</p>
    </main>
  );
}

ルートグループ内での unauthorized.tsx 配置

app/
├── unauthorized.tsx          # アプリ全体のデフォルト unauthorized ページ
├── (admin)/
│   ├── unauthorized.tsx      # admin グループ専用(より詳細なメッセージ)
│   ├── layout.tsx            # admin 共通レイアウト
│   └── dashboard/
│       └── page.tsx
└── (public)/
    └── login/
        └── page.tsx

not-found との使い分け

// app/posts/[id]/page.tsx — not-found と unauthorized の両方を使う例
import { cookies } from "next/headers";
import { notFound, redirect, unauthorized } from "next/navigation";

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session_id");

  if (!sessionId) {
    redirect("/login");
  }

  // 実際のアプリでは DB から取得
  const post = await fetchPost(id);

  // 投稿が存在しない → 404
  if (!post) {
    notFound();
  }

  // 投稿は存在するが閲覧権限がない → 403 unauthorized
  if (post.isPrivate && post.authorId !== getCurrentUserId()) {
    unauthorized();
  }

  return <article>{/* ... */}</article>;
}

// ダミー関数(サンプル用)
async function fetchPost(id: string) {
  return id === "1" ? { id: "1", title: "テスト", isPrivate: true, authorId: "user-2" } : null;
}

function getCurrentUserId() {
  return "user-1";
}

ポイント

  • unauthorized() は Next.js 15 で追加された関数。notFound() と同様にコンポーネントのレンダリングを中断し、最も近い unauthorized.tsx を表示する
  • not-foundunauthorized の使い分け: リソース自体が存在しない(404)場合は notFound()、リソースは存在するが権限がない(403)場合は unauthorized() を使う。セキュリティ上、権限不足の場合にも notFound() を使って存在を隠すケースもある
  • unauthorized.tsx はルートグループ単位で配置でき、管理者エリアと一般エリアで異なるメッセージを表示できる
  • 未認証(Cookie なし)の場合は redirect("/login") でログインページへ誘導する。unauthorized() は「ログイン済みだが権限が足りない」状態に使う
  • getCurrentUserRole のようなロール取得関数は Server Component 内で呼び出し、クライアントにロール情報を露出させない

注意点

nextjs-protected-layout は layout 単位の一括認証保護。nextjs-auth-redirect は認証状態による redirect 制御。nextjs-middleware-auth は全リクエスト横断保護。これは認証済みユーザーが権限不足の場合に専用ページ(unauthorized)を表示するパターンに特化。not-found との責務の違いも示す。

関連サンプル