DELOGs
JWTで保護されたユーザ一覧を実装する ─ 認証・ロール・一覧表示まで

JWTで保護されたユーザ一覧を実装する ─ 認証・ロール・一覧表示まで

ログイン済みのadminユーザーだけにユーザー一覧を表示します。JWT認証の保護ルートとRBAC導入の第一歩となる実装

初回公開日

最終更新日

0. はじめに:なぜ「保護ルート」が重要か

Webアプリケーションにおいて、「認証されていないユーザーには見せない」ページを設けるのはとても基本的な要件です。たとえば管理者しかアクセスできないダッシュボード、ログイン済みのユーザーだけが見られる会員ページなどがそれにあたります。
これを「保護されたルート(Protected Route)」と呼び、適切に制御しないと、URLを直接打ち込むことで本来アクセスできないページを閲覧されてしまうリスクが生じます。
今回は、Next.js+Shadcn/ui環境においてJWT認証による保護ルートの実装を体験していきます。 あわせて、認証ユーザーの中でも「admin」ロールだけが見られる情報を表示する構成を用意し、RBAC(ロールベースアクセス制御)の準備も進めます。

1. 今回実装するページの概要と構成

今回は、ログイン後にアクセスできる /users ページ を実装します。以下のような構成になっています:
txt
1+-----------------------+ 2| /login | ← JWTログイン(すでに過去記事実装済み) 3+-----------------------+ 4 ⇩ 認証後 5+-----------------------+ 6| /dashboard | ← ログイン後の初期ページ(すでに過去記事実装済み) 7+-----------------------+ 8 ⇩ adminのみリンク表示 9+-----------------------+ 10| /users | ← 今回実装する保護ルート 11+-----------------------+ 12| - JWT検証 | 13| - adminのみ表示 | 14| - Shadcn DataTable | 15+-----------------------+
この /users ページは、JWTトークンで保護されたルートであり、トークンがない、もしくは不正な場合は AuthenticatedArea.tsx によって自動でブロックされます。
さらに、認証済みユーザーであっても、ロールが admin でない場合は一覧を表示せず、別のメッセージを表示する仕様です。
このように、「JWTでログイン済みかどうか」だけでなく、「どんな権限(ロール)か」まで考慮したアクセス制御に踏み出す構成になっています。
なお今回の実装は、以下の過去記事の内容をベースにした続編となっています。
もし、上記を未読の方はさらりとご確認ください。
ログイン処理とトークンの発行・保存はすでに完了している前提で進めていきます。

2. 仮データの作成(lib/users.ts)

この /users ページで表示するユーザー一覧は、PrismaやDBを使わずに、固定の「仮データ」で構成します(PrismaやDBを使用する内容は別途実施予定です)。
この段階ではまだDBとの接続は行わないため、src/lib/users.ts というファイルにユーザー情報を配列として記述します。
まずはファイルの全体像を確認しましょう:
ts : users.ts
1export type User = { 2 id: number; 3 name: string; 4 email: string; 5 role: "admin" | "user"; 6}; 7 8export const users: User[] = [ 9 { id: 1, name: "佐藤 一郎", email: "ichiro@example.com", role: "admin" }, 10 { id: 2, name: "鈴木 花子", email: "hanako@example.com", role: "user" }, 11 { id: 3, name: "高橋 次郎", email: "jiro@example.com", role: "user" }, 12]; 13

解説:型定義とサンプルデータ

  • User 型を定義しています。
    • id:数値型のユーザーID。
    • name:表示用の名前。
    • email:メールアドレス。
    • role:アクセス制御に使うロール。今回は 'admin' または 'user' のどちらかに限定しています。
  • users 配列には、3人のサンプルユーザーを定義しています。
    • 一人目(佐藤さん)のロールが admin
    • 他の2人は通常の user ロールです。
これにより、「adminユーザーだけがユーザー一覧を閲覧できる」仕組みを後述のUIで実装できます。

どこに置くべき?

このファイルは src/lib/users.ts に配置するのが自然です。lib/ ディレクトリはプロジェクト内で「データやロジックをまとめておく場所」として使われます。
将来的にPrismaを導入した際は、この仮データは prisma.user.findMany() に差し替えるだけでOKになるよう設計しておきます。
次は、この仮データを表示するためのコンポーネントを作成していきましょう。

3. 認証チェックとDataTableの表示(UsersClient.tsx)

この /users ページでは、JWTで認証されたユーザーだけがアクセスできるように制御し、さらにその中でも admin ロールのユーザーだけがユーザー一覧を閲覧できるようにします。
この仕組みの中核となるのが、以下の3つの要素です:
  • AuthenticatedArea.tsx:JWTトークンの検証と保護ルートの共通コンポーネント
  • UsersClient.tsx:認証情報を受け取り、UIを構成するコンポーネント
  • DataTable 関連のファイル群:Shadcn/ui のテーブル表示
まずはプロジェクト全体の構成を確認しましょう。

ページ構成図

txt
1src/ 2├─ app/ 3│ ├─ users/ 4│ │ ├─ page.tsx ← Server Component(ルーティングのみ) 5│ │ ├─ UsersClient.tsx ← 認証チェックと一覧表示(Client) 6│ │ └─ columns.ts ← DataTableのカラム定義 7├─ components/ 8│ ├─ AuthenticatedArea.tsx ← 認証されたユーザーのみ通過できる保護領域 9│ └─ shared/ 10│ └─ data-table.tsx ← 汎用DataTableコンポーネント 11└─ lib/ 12 └─ users.ts ← 仮のユーザーデータ(前章で作成)

DataTable を使うための準備

Shadcn/uiDataTable は、おそらくは管理画面で情報一覧を表示する際に、マストで必要なものです。とても便利で動作も軽快です。 Shadcn/uiDataTable は、内部的に @tanstack/react-table をベースに構成されています。以下のコマンドでテーブルコンポーネントを追加しておきましょう。
プロジェクトディレクトリ配下で下記を実行:
zsh
1cd xxx/xxx/project/login-form 2 3npx shadcn-ui@latest add table 4npm install @tanstack/react-table
これにより components/ui/ 配下に table.tsx などのコンポーネントが追加され、共通UIとして活用できます。

Server Component(page.tsx)

まず /users ページは App Router において Server Component として構成されます。ここでは UI や認証判定は行わず、Client Component を呼び出すだけにとどめます。
src/app/users/page.tsxの内容:
tsx : page.tsx
1import UsersClient from "./UsersClient"; 2 3export default function UsersPage() { 4 return <UsersClient />; 5}

認証チェック+表示処理(UsersClient.tsx)

このファイルが /users ページの実質的な処理本体です。 AuthenticatedArea.tsx をラップし、トークンから取得したユーザー情報を UsersTable に渡します。
src/app/users/UsersClient.tsxの内容:
tsx : UsersClient.tsx
1"use client"; 2 3import AuthenticatedArea, { User } from "@/components/AuthenticatedArea"; 4import { users } from "@/lib/users"; 5import { columns } from "./columns"; 6import { DataTable } from "@/components/shared/data-table"; 7 8export default function UsersClient() { 9 return ( 10 <AuthenticatedArea> 11 {(user: User) => ( 12 <div className="mx-auto max-w-4xl px-4 pt-10"> 13 <h1 className="mb-4 text-2xl font-bold">ユーザー一覧</h1> 14 <p className="text-muted-foreground mb-6 text-sm"> 15 ログイン中ユーザー:{user.email}(ロール: {user.role}16 </p> 17 18 <DataTable columns={columns} data={users} /> 19 </div> 20 )} 21 </AuthenticatedArea> 22 ); 23}

解説:

  • AuthenticatedArea は、JWTトークンの有効性をチェックする共通コンポーネントです。
  • 子関数として (user: User) => ... を受け取る構造になっており、トークンから復元したユーザー情報を user として扱えます。
  • DataTable には仮データとカラム定義を渡します。
  • この時点ではロールの制御(admin判定)はまだしていません。次章で実装します。

カラム定義(columns.ts)

一覧表示に必要な「列の定義」は別ファイルで管理します。
src/app/users/columns.tsの内容:
ts : columns.ts
1"use client"; 2 3import { ColumnDef } from "@tanstack/react-table"; 4import type { User } from "@/lib/users"; 5 6export const columns: ColumnDef<User>[] = [ 7 { 8 accessorKey: "id", 9 header: "ID", 10 }, 11 { 12 accessorKey: "name", 13 header: "名前", 14 }, 15 { 16 accessorKey: "email", 17 header: "メールアドレス", 18 }, 19 { 20 accessorKey: "role", 21 header: "ロール", 22 }, 23];

解説:

  • ColumnDef<User>[] によって、User 型に即した型安全なカラム定義が可能になります。
  • accessorKey で各プロパティに対応する列を指定し、header で表示名を指定しています。
  • 複雑なレンダリングやアクション列などもこのファイルで柔軟に拡張できます。

共通DataTableコンポーネント(data-table.tsx)

このファイルは、Shadcn/ui<Table> と、@tanstack/react-table を組み合わせて汎用的に再利用できる「一覧表示コンポーネント」を構築したものです。
src/components/shared/data-table.tsxの内容:
tsx : data-table.tsx
1"use client"; 2 3import { 4 Table, 5 TableBody, 6 TableCell, 7 TableHead, 8 TableHeader, 9 TableRow, 10} from "@/components/ui/table"; 11 12import { 13 flexRender, 14 getCoreRowModel, 15 useReactTable, 16 ColumnDef, 17} from "@tanstack/react-table"; 18 19type Props<TData, TValue> = { 20 columns: ColumnDef<TData, TValue>[]; 21 data: TData[]; 22}; 23 24export function DataTable<TData, TValue>({ 25 columns, 26 data, 27}: Props<TData, TValue>) { 28 const table = useReactTable({ 29 data, 30 columns, 31 getCoreRowModel: getCoreRowModel(), 32 }); 33 34 return ( 35 <div className="rounded-md border"> 36 <Table> 37 <TableHeader> 38 {table.getHeaderGroups().map((headerGroup) => ( 39 <TableRow key={headerGroup.id}> 40 {headerGroup.headers.map((header) => ( 41 <TableHead key={header.id}> 42 {header.isPlaceholder 43 ? null 44 : flexRender( 45 header.column.columnDef.header, 46 header.getContext(), 47 )} 48 </TableHead> 49 ))} 50 </TableRow> 51 ))} 52 </TableHeader> 53 <TableBody> 54 {table.getRowModel().rows.map((row) => ( 55 <TableRow key={row.id}> 56 {row.getVisibleCells().map((cell) => ( 57 <TableCell key={cell.id}> 58 {flexRender(cell.column.columnDef.cell, cell.getContext())} 59 </TableCell> 60 ))} 61 </TableRow> 62 ))} 63 </TableBody> 64 </Table> 65 </div> 66 ); 67}

ここで何が起きているのか?(詳細解説):

1. 型定義:Props<TData, TValue>
tsx
1type Props<TData, TValue> = { 2 columns: ColumnDef<TData, TValue>[] 3 data: TData[] 4}
  • ジェネリクス(TData, TValue)を使うことで、再利用性と型安全性を確保。
  • この DataTable は どんな型のデータでも表示できる 汎用コンポーネントです。
  • ColumnDef<TData, TValue>[] は @tanstack/react-table の定義済み型で、「どんな列を表示するか」を記述するもの。
2. useReactTable の構成
tsx
1const table = useReactTable({ 2 data, 3 columns, 4 getCoreRowModel: getCoreRowModel(), 5})
  • data:表示したい配列(例:users)
  • columns:列の定義(例:columns.ts)
  • getCoreRowModel():内部的に行を「構造化」してくれる関数(必須)
この設定を行うことで、table.getHeaderGroups() や table.getRowModel() といった「整った形のデータアクセスAPI」が使えるようになります。
3. 表ヘッダーの表示
tsx
1{table.getHeaderGroups().map(headerGroup => ( 2 <TableRow> 3 {headerGroup.headers.map(header => ( 4 <TableHead> 5 {flexRender(header.column.columnDef.header, header.getContext())} 6 </TableHead> 7 ))} 8 </TableRow> 9))}
  • getHeaderGroups() は複数行のヘッダー(例:グループ化ヘッダー)に対応。
  • flexRender() は、ヘッダーに関数が設定されていたときに、実行して描画してくれる関数。
  • 例えば、header: ({ column }) => <strong>{column.id}</strong> のような動的描画が可能。
3. 表本体の表示
tsx
1{table.getRowModel().rows.map(row => ( 2 <TableRow> 3 {row.getVisibleCells().map(cell => ( 4 <TableCell> 5 {flexRender(cell.column.columnDef.cell, cell.getContext())} 6 </TableCell> 7 ))} 8 </TableRow> 9))}
  • getRowModel().rows で、各行の構造化された情報が取得できる。
  • getVisibleCells() によって、隠し列などを除いた表示対象セルだけが取得される。
  • 各セルも flexRender() で関数描画に対応。これにより、単なる文字列だけでなく、ボタンやチェックボックス、アイコンなども柔軟に埋め込めます。

この data-table.tsx を使うメリット:

  • Shadcn/ui<Table> スタイルをそのまま活かせる
  • 列定義とデータが完全に分離され、保守性が高い
  • 再利用しやすく、複数ページで同じ形式のテーブルを共通化できる
  • flexRender() によって高いカスタマイズ性を実現できる
この時点で、「JWT認証済みのユーザーが一覧ページにアクセスし、仮データを表示できる」構成が整いました。 次の章ではいよいよ user.role を使って、adminロールのみ表示できるよう制御する RBAC 機能を実装していきます。

4. ロールによる表示制御(adminのみに表示)

JWTでログインしたユーザーの中でも、「admin」ロールを持つユーザーだけが /users ページのユーザー一覧を閲覧できるように制御していきます。これにより、**認証に加えて権限レベルの制御(RBAC)**も導入されることになります。

実装方針

  • AuthenticatedArea.tsx ですでに JWT トークンから user 情報を取得できる構成になっています。
  • この user オブジェクトの role プロパティが "admin" のときだけ、ユーザー一覧(DataTable)を表示します。
  • それ以外のユーザーには、アクセス不可のメッセージを表示します。

修正対象:UsersClient.tsx

前章で作成した UsersClient.tsx に、ロール判定の条件を加えましょう。
src/app/users/UsersClient.tsxの内容:
tsx
1"use client"; 2 3import AuthenticatedArea, { User } from "@/components/AuthenticatedArea"; 4import { users } from "@/lib/users"; 5import { columns } from "./columns"; 6import { DataTable } from "@/components/shared/data-table"; 7 8export default function UsersClient() { 9 return ( 10 <AuthenticatedArea> 11 {(user: User) => ( 12 <div className="mx-auto max-w-4xl px-4 pt-10"> 13 <h1 className="mb-4 text-2xl font-bold">ユーザー一覧</h1> 14 <p className="text-muted-foreground mb-6 text-sm"> 15 ログイン中ユーザー:{user.email}(ロール: {user.role}16 </p> 17 18 {user.role === "admin" ? ( 19 <DataTable columns={columns} data={users} /> 20 ) : ( 21 <div className="text-destructive font-medium"> 22 このページは admin ロールのユーザーのみ閲覧可能です。 23 </div> 24 )} 25 </div> 26 )} 27 </AuthenticatedArea> 28 ); 29}

解説:RBACの基本制御

  • user.role === "admin" によって分岐し、条件付きレンダリングを行っています。
  • このように UI 側でロールによる制御を入れるだけでも、簡易的なRBACが実現できます。
  • なお、本格的なRBACは「APIレベル」でも制御する必要があります(これは次章以降で扱います)。

注意:UIだけの制御では不十分な理由

この段階では、あくまでフロントエンドでの表示制御のみです。 つまり、仮に悪意のあるユーザーが DevTools でロールを偽装してしまえば、表示されてしまうリスクがあります。
このため、本番環境では以下が必須になります:
項目必須?解説
UIでのロール制御ユーザー体験上のガード(今回の実装)
APIでのロール検証データ取得APIで admin 判定を入れる
サーバー側でのガードSSR/ISRの段階でも非公開制御を行う場合
今回はまず「UIでの明示的な制御」を行い、次にAPIでの制御を実装するステップへとつなげていきます。

5. ダッシュボードから /users へ遷移するリンクの設置

このステップでは、ログイン後に表示される /dashboard ページに、/users ページへのリンクを追加します。ただし、adminロールを持つユーザーのみに表示するリンクとすることで、自然な形でRBACの導入を促します。

対象ファイル:DashboardClient.tsx

すでに /dashboardDashboardClient.tsx という Client Component によって描画されています。ここで AuthenticatedArea を使ってログイン済みユーザー情報が取得できる構成でした。(過去記事JWTログインAPIをNext.jsで実装する)
ここにリンクを条件付きで追加します。
src/app/dashboard/DashboardClient.tsxの内容に追記:
diff : DashboardClient.tsx
1"use client"; 2 3+ import Link from "next/link"; 4import AuthenticatedArea, { User } from "@/components/AuthenticatedArea"; 5+ import { Button } from "@/components/ui/button"; 6 7export default function DashboardClient() { 8 return ( 9 <AuthenticatedArea> 10 {(user: User) => ( 11 <div className="mx-auto max-w-xl pt-10"> 12 <h1 className="mb-4 text-2xl font-bold">ダッシュボード</h1> 13 <p>ようこそ、{user.email} さん</p> 14 <p className="text-muted-foreground mt-2 text-sm"> 15 アカウントID: {user.sub} 16 </p> 17 {user.role && <p>ロール: {user.role}</p>} 18+ {user.role === "admin" && ( 19+ <div className="mt-6"> 20+ <Link href="/users"> 21+ <Button variant="outline">ユーザー一覧ページへ</Button> 22+ </Link> 23+ </div> 24+ )} 25 </div> 26 )} 27 </AuthenticatedArea> 28 ); 29}

解説:リンクの条件付き表示

  • user.role === "admin" によって、adminユーザーのみに表示されるリンクを実装。
  • Shadcn/ui の コンポーネントで装飾し、Next.js の でページ遷移。
  • 保護ページ /users への「明示的な導線」として非常に重要なパーツです。
これにより、ログイン直後のユーザーにとって /users ページが発見可能となり、UXとしても自然な保護ルート導線が構築できます。

6. テスト

ここまでの内容をテストします。過去記事JWTログインAPIをNext.jsで実装するsrc/app/api/login/route.tsにとりあえず、IDとパスワードをベタ打ちしているところを増やします。
src/app/api/login/route.tsの追記:
diff : route.ts
1import { NextResponse } from "next/server"; 2import { signToken } from "@/lib/jwt"; 3 4// 仮のユーザーデータ(デモ用) 5- const dummyUser = { 6- accountId: "demoAccount0123", 7- email: "demo@example.com", 8- password: "passWord0123456", // 本番ではハッシュ化必須 9- role: "admin", 10- }; 11 12+ const dummyUsers = [ 13+ { 14+ accountId: "adminAccount0123", 15+ email: "admin@example.com", 16+ password: "adminPass0123456", 17+ role: "admin", 18+ }, 19+ { 20+ accountId: "userAccount1234", 21+ email: "user1@example.com", 22+ password: "userPass1234567", 23+ role: "user", 24+ }, 25+ { 26+ accountId: "testuserAccount", 27+ email: "user2@example.com", 28+ password: "testPassword000", 29+ role: "user", 30+ }, 31+ ]; 32 33export async function POST(req: Request) { 34 const body = await req.json(); 35 const { accountId, email, password } = body; 36 37 // 入力値の検証(実際はZodやDB連携が必要) 38- if ( 39- accountId !== dummyUser.accountId || 40- email !== dummyUser.email || 41- password !== dummyUser.password 42- ) { 43- return NextResponse.json({ error: "認証に失敗しました" }, { status: 401 }); 44- } 45 46+ // 仮ユーザーの中から一致するものを検索 47+ const matchedUser = dummyUsers.find( 48+ (user) => 49+ user.accountId === accountId && 50+ user.email === email && 51+ user.password === password, 52+ ); 53 54+ if (!matchedUser) { 55+ return NextResponse.json({ error: "認証に失敗しました" }, { status: 401 }); 56+ } 57 58 // JWTのペイロード 59- const payload = { 60- sub: dummyUser.accountId, 61- email: dummyUser.email, 62- role: dummyUser.role, 63- }; 64 65+ const payload = { 66+ sub: matchedUser.accountId, 67+ email: matchedUser.email, 68+ role: matchedUser.role, 69+ }; 70 71 // トークン発行 72 const token = await signToken(payload); 73 74 // クライアントに返す(今回はJSONで返却。Cookie保存ではない) 75 return NextResponse.json({ token }); 76}
これで「保護ルートの挙動」「RBACの動作確認」「正しいバリデーション仕様の準拠」がすべて可能になります。
npm run devで起動して見ましょう。
admin権限のユーザでログインしたとき
  • ダッシュボード
admin権限のダッシュボート
  • ユーザ一覧
admin権限のユーザ一覧
user権限のユーザでログインしたとき
  • ダッシュボード
user権限のダッシュボート
  • ユーザ一覧を直打ちしたとき
user権限のユーザ一覧
上図、それぞれのように意図した動作が確認できました。

7. まとめと次ステップ(RBAC本格導入へ)

今回の記事では、Next.js App Router + JWT認証による「保護ルート」の仕組みを /users ページを通じて実装しました。Shadcn/ui の DataTable を組み合わせることで、認証・認可が効いたユーザー一覧ページを自然なUXで提供できる構成となりました。
本記事のポイントを振り返ると以下の通りです:
  • ✅ AuthenticatedArea.tsx によるJWT保護ルートの再利用
  • ✅ 仮データによるユーザー一覧表示(Prisma非依存)
  • ✅ Shadcn/ui の DataTable による実用的な表形式レイアウト
  • ✅ ロールが admin のユーザーだけに一覧を表示(RBACの第一歩)
  • ✅ /dashboard からのリンク設置による自然な導線設計
今回はまだ仮データのままで進めていますが、次回以降のステップでは PrismaとPostgreSQLを用いたDB連携 に進み、より現実的なユーザー管理に踏み出します。
さらに、admin・user の区別だけでなく、「ロールごとにアクセス可能なページや操作が異なる」ような RBAC(ロールベースアクセス制御) を本格導入していく予定です。
次の記事では、以下を予定しています:
  • ✅ Prismaスキーマに User モデルを定義し、DB連携へ切り替え
  • ✅ role に基づくAPIガードの導入
  • ✅ Admin専用ページや操作の保護(UI+バックエンド両方)

参考文献

この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。