DELOGs
[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UI

管理画面フォーマット制作編 #8
ログイン後404ページ + ログイン前のパスワード忘れ導線UI

管理画面に「ログイン後の404ページ」と、ログイン前にユーザが管理者へ依頼できる「パスワード忘れ導線UI」を追加

初回公開日

最終更新日

0. はじめに

これまでの管理画面シリーズでは、ログイン画面やユーザ管理、サイドバーメニュー管理など、利用者が日常的に操作する基本機能を整備してきました。今回の記事では、それらに加えて「もしもの時」に備えるUIを実装します。

本記事で取り扱うテーマ

本記事のテーマは以下の2点です。
  1. ログイン後の404ページ
    ログイン後にユーザが誤ったURLへアクセスした際に表示される専用の404ページを設けます。未ログイン時のアクセスはログイン画面に誘導される設計とし、ログイン済みの場合のみ404ページを表示する構成とします。
  2. ログイン前のパスワード忘れ導線UI
    利用者がパスワードを忘れた場合、一般的な「自己リセット」はセキュリティ上のリスクが高いため採用しません。代わりに、利用者がアカウント管理者へ「再発行依頼」を送信できるUIを用意します。管理者は依頼を受けて一時パスワードを発行し、利用者に渡す流れです。

技術スタックと前提環境

前提とする技術スタックとコードスタイルを確認します。既存の書き方を踏襲します。
Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xApp Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSS
  • Next.js 15(App Router)/TypeScript
  • UIは shadcn/ui を用い、フォームは react-hook-form + zodResolver

この記事の位置づけ

本記事は、 管理画面フォーマット作成編 #7 サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで の続きとなります。 前回(#7)ではサイドバーメニュー管理UIを作成し、管理画面としての「操作対象を選ぶ」仕組みを整えました。今回の実装は一見地味ですが、実サービスを運用する上で欠かせない「例外時の体験」と「パスワードリカバリの安全設計」を担う重要な要素となります。
次の章からは、それぞれのUIをどのように構成するかを具体的に見ていきます。

1. ログイン後404ページの設計

管理画面を利用する中で、ユーザが誤ったURLを入力したり、存在しないページへブックマークからアクセスするケースは必ず発生します。こうした場合に「ただ真っ白な画面」や「ブラウザの標準エラー」を出すのではなく、サービス全体として統一感のある404ページを提供することが重要です。
ここでは「ログイン済みの場合にのみ表示される404ページ」を設計します。未ログインのユーザにはログイン画面が表示される仕組みを維持し、二重の404ページは設けません。

なぜログイン後専用の404が必要か?

  • ユーザ体験の一貫性
    サイドバーやダッシュボードがある「管理画面の世界」にいる状態で404が出るとき、デザインも同じレイアウトであるべきです。
  • セキュリティ
    未ログイン時に「管理画面用の404」が出てしまうと、攻撃者に「内部に管理画面が存在する」ことを示す余計な情報を与えることになります。そのため、未ログインの場合は従来通りログイン画面に誘導します。
  • メンテナンス性
    /not-found という共通のルートに統一することで、複数のページに404用の処理を書かずに済みます。

キャッチオールルートとリダイレクトの仕組み

Next.js の App Router には「キャッチオールルート」という機能があります。
これを使うと、存在しないURLにアクセスされた際に、まとめて特定のページに誘導できます。
txt
1(protected) 2├─ dashboard/ 3├─ users/ 4├─ not-found.tsx ← 専用の404ページ 5└─ […catchAll]/ ← 存在しないルートをすべて拾う

[...catchAll] の役割とリダイレクトファイル作成

キャッチオールルート [...catchAll] を用意し、その中で 404を返すようにします。
これにより、存在しないURLにアクセスされたときでも、最終的に /not-found ページを表示できます。
src/app/(protected)/[...catchAll]/page.tsxを下記の内容で作成します。
tsx
1// src/app/(protected)/[...catchAll]/page.tsx 2import { notFound } from "next/navigation"; 3 4/** 5 * (protected) 配下でマッチしない全ルートを捕捉し、HTTP 404 を返す。 6 * レンダリングされるUIは同セグメントの not-found.tsx。 7 */ 8export default function ProtectedCatchAllPage() { 9 notFound(); // ← ここで 404 を返す 10}
[...catchAll]/page.tsx は、(protected) 配下でルートに一致しない URL をすべて受け止め、notFound() を呼びます。これにより HTTP 404 が返り、同階層の not-found.tsx が描画されます。

2. 404ページの実装(HTTP 404 + 独自UI)

要件は「HTTPは404で返す」かつ「独自デザインのUIを表示」です。
Next.js App Routerでは、notFound() を呼ぶと ステータス404 が返り、同じルートセグメント階層で最も近い not-found.tsx(特別ファイル)がレンダリングされます。
したがって、(/not-found/page.tsx を作るのではなく) (protected)/not-found.tsx を作成し、そこに独自UIを置くのが正解です。
存在しないURLは (protected)/[...catchAll]/page.tsx で拾い、notFound() を呼ぶことで 404 とし、(protected)/not-found.tsx のUI を表示します。
txt
1(app) 2 └─ (protected) 3 ├─ dashboard/ 4 ├─ users/ 5 ├─ not-found.tsx ← ★ 特別ファイル(このUIが404時に表示される) 6 └─ [...catchAll]/ 7 └─ page.tsx ← ★ 前章で作成。存在しないURLを捕捉して notFound() を呼ぶ

404ページの作成

tsx
1// src/app/(protected)/not-found.tsx 2import type { Metadata } from "next"; 3import { SidebarTrigger } from "@/components/ui/sidebar"; 4import { 5 Breadcrumb, 6 BreadcrumbItem, 7 BreadcrumbList, 8 BreadcrumbPage, 9} from "@/components/ui/breadcrumb"; 10import { Separator } from "@/components/ui/separator"; 11import { FileWarning } from "lucide-react"; 12import NotFoundClient from "./_components/not-found-client"; 13 14// ページメタ(任意)。404はインデックス対象外なので簡素でOK。 15export const metadata: Metadata = { 16 title: "ページが見つかりません", 17}; 18 19export default function ProtectedNotFound() { 20 return ( 21 <> 22 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 23 <div className="flex items-center gap-2 px-4"> 24 <SidebarTrigger className="-ml-1" /> 25 <Separator 26 orientation="vertical" 27 className="mr-2 data-[orientation=vertical]:h-4" 28 /> 29 <Breadcrumb> 30 <BreadcrumbList> 31 <BreadcrumbItem> 32 <BreadcrumbPage>ページが見つかりません</BreadcrumbPage> 33 </BreadcrumbItem> 34 </BreadcrumbList> 35 </Breadcrumb> 36 </div> 37 </header> 38 <div className="flex h-full flex-col items-center justify-center gap-6 p-6 text-center"> 39 <FileWarning className="text-muted-foreground mx-auto h-12 w-12" /> 40 <div className="space-y-2"> 41 <p className="text-2xl font-bold">ページが見つかりません</p> 42 <p className="text-muted-foreground"> 43 URLをご確認いただくか、サイドバーから目的のページを選択してください。 44 </p> 45 </div> 46 {/* ルーター操作などクライアント挙動は子コンポーネントへ分離 */} 47 <NotFoundClient /> 48 </div> 49 </> 50 ); 51}
(protected)/not-found.tsx特別ファイルで、同セグメント内で notFound() が呼ばれると必ずこのUIが描画されます。
ボタンクリックなど クライアント専用の挙動useRouter 等)はサーバーコンポーネントから直接使えないため、子のクライアントコンポーネントに分離します。

404ページ用のコンポーネント作成

tsx
1// src/app/(protected)/_components/not-found-client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { Button } from "@/components/ui/button"; 6 7export default function NotFoundClient() { 8 const router = useRouter(); 9 10 return ( 11 <div className="flex flex-wrap justify-center gap-3"> 12 <Button onClick={() => router.push("/dashboard")}> 13 ダッシュボードへ戻る 14 </Button> 15 <Button variant="outline" onClick={() => router.back()}> 16 前のページに戻る 17 </Button> 18 </div> 19 ); 20}
_components/not-found-client.tsxクライアントコンポーネントです。
  • 「ダッシュボードへ戻る」→ /dashboard へ遷移
  • 「前のページに戻る」→ router.back() でブラウザ履歴に戻る
これで HTTPは404、かつ 独自UI を (protected) セグメント内で統一的に表示できます。
ログイン後の404ページ

3. パスワード忘れフローの要件整理

ここからは「ログイン前のパスワード忘れ」をどう扱うかを整理します。
一般的なWebサービスでは「パスワードを忘れたらメールで自己リセット」という仕組みが多いですが、今回の管理画面では採用しません。
法人利用を前提としたセキュリティ設計として、「利用者は自己リセット不可」→「管理者が依頼を受けて再発行」 という流れにします。

なぜ自己リセットを禁止するのか

  • セキュリティリスクを避けるため
    攻撃者がメールアドレスやアカウントIDを推測できると、自己リセット機能は突破口になります。
  • 法人向け運用を前提にしているため
    アカウントID(accountId)は企業単位で管理され、利用者は社内の一員。
    パスワード再発行は社内管理者の責任で行う方が自然です。
  • 監査・内部統制を強化できる
    どの利用者が「パスワードを忘れた」と申告し、誰が「再発行したか」を明示できるため、コンプライアンスに強い設計になります。

フローの全体像

パスワード忘れ時の流れを、利用者と管理者に分けて整理すると次の通りです。
  1. 利用者側
    • ログイン画面から「パスワードを忘れた方へ」リンクをクリック
    • accountId・メールアドレス・任意のメモを入力して依頼送信
    • 成否に関わらず「依頼を受け付けました」と表示(存在確認は返さない)
  2. 管理者側
    • 管理画面の「パスワード再発行依頼一覧」に通知が届く
    • 対象ユーザを確認し、一時パスワードを発行
    • 発行したパスワードを安全に通知(初回ログイン時に変更を強制することも可能)
txt
1利用者 → 依頼フォーム送信 23 管理者 → 一覧で確認 45 管理者 → 一時パス発行 67 利用者 → 新しいパスワードでログイン

必要なUI

  • 利用者側
    • /password/forgot ページに依頼フォーム
    • 入力項目は accountIdemail・備考(任意)
  • 管理者側
    • /password-requests ページで依頼一覧を表示
    • 詳細ページで「対象ユーザ選択」「一時パスワード発行」「次回変更強制」のUIを提供
これにより、利用者は依頼まで・管理者が再発行という責任分担が明確になります。

セキュリティ要件の整理

  • 存在確認を返さない
    → 常に「依頼を受け付けました」と表示する。
  • 一時パスワードは一度きり表示
    → 発行直後にコピー可能にするが、再表示は不可。
  • 監査ログを確保
    → 誰が再発行したかを processedBy として保存する。
  • 次回ログイン時に変更を強制
    → 一時パスで入ったら必ず本人に変更させる。
この4点を守ることで、セキュリティとユーザ体験の両立を目指します。

4. ユーザ側の依頼フォーム実装

この節では、下部構造から順に 実装し、バリデーションは既存スキーマを再利用します。
まず「スキーマの再利用のための最小リファクタ」を行い、その後に フォーム → クライアント → ページ の順で作成します。

共通スキーマの再利用方針(最小リファクタ)

  • accountIdsrc/lib/login/schema.ts から accountIdSchema を新規エクスポート
  • emailsrc/lib/users/schema.tsemailSchema をエクスポート化
  • これにより、依頼フォーム側では バリデーション定義を重複させず に済みます。
ts
1// 修正: src/lib/login/schema.ts(変更箇所のみ抜粋) 2 3/* 途中は既存のまま */ 4 5// 変更: 既存の emailSchema を export へ昇格 6export const emailSchema = z.email("メールアドレスの形式が正しくありません"); 7 8/* 途中は既存のまま */ 9 10// 追加: 共通で使い回すためエクスポート 11export const accountIdSchema = z 12 .string() 13 .min(15, "アカウントIDは15文字以上で入力してください。") 14 .regex(/[A-Z]/, "大文字を1文字以上含めてください。") 15 .regex(/[a-z]/, "小文字を1文字以上含めてください。") 16 .regex(/[0-9]/, "数字を1文字以上含めてください。"); 17 18export const loginSchema = z.object({ 19 accountId: accountIdSchema, 20 email: z.email("有効なメールアドレスを入力してください。"), 21 password: z 22 .string() 23 .min(15, "パスワードは15文字以上で入力してください。") 24 .regex(/[A-Z]/, "大文字を1文字以上含めてください。") 25 .regex(/[a-z]/, "小文字を1文字以上含めてください。") 26 .regex(/[0-9]/, "数字を1文字以上含めてください。"), 27});
  • emailSchemaexport へ昇格し、他所から再利用可能にしました。
  • 既存の userCreateSchema / userUpdateSchema などはそのまま利用できます。
  • accountIdSchema単独でエクスポート し、他のフォームからも同じルールを参照できるようにしました。
  • loginSchema は従来通りの構成で動作します。

フォーム本体(コンポーネント): src/components/login/password-forgot-form.tsx

フォームは共通化の方針 に従い src/components/ 配下へ。
UIの構成・記述スタイルは users/user-form.tsxできるだけ揃え、Zod は上記でエクスポートしたスキーマを合成して使います。
tsx
1// src/components/login/password-forgot-form.tsx 2"use client"; 3 4import { useForm } from "react-hook-form"; 5import { zodResolver } from "@hookform/resolvers/zod"; 6import { z } from "zod"; 7 8import { 9 Form, 10 FormField, 11 FormItem, 12 FormLabel, 13 FormControl, 14 FormMessage, 15 FormDescription, 16} from "@/components/ui/form"; 17import { Card, CardContent, CardFooter } from "@/components/ui/card"; 18import { Input } from "@/components/ui/input"; 19import { Button } from "@/components/ui/button"; 20 21import { accountIdSchema } from "@/lib/users/schema"; 22import { emailSchema } from "@/lib/users/schema"; 23 24/* ========================= 25 スキーマ(合成) 26 ========================= */ 27export const forgotRequestSchema = z.object({ 28 accountId: accountIdSchema, 29 email: emailSchema, 30 note: z.string().optional(), 31}); 32export type ForgotRequestValues = z.infer<typeof forgotRequestSchema>; 33 34/* ========================= 35 公開インターフェース(型) 36 ========================= */ 37type Props = { 38 onSubmit: (values: ForgotRequestValues) => void | Promise<void>; 39 onCancel?: () => void; 40 loading?: boolean; 41 /** 送信済み状態(true の間は入力不可・ボタン群の代わりにラベル表示) */ 42 submitted?: boolean; 43}; 44 45/* ========================= 46 エクスポート本体 47 ========================= */ 48export default function PasswordForgotForm({ 49 onSubmit, 50 onCancel, 51 loading, 52 submitted, 53}: Props) { 54 const form = useForm<ForgotRequestValues>({ 55 resolver: zodResolver(forgotRequestSchema), 56 defaultValues: { accountId: "", email: "", note: "" }, 57 mode: "onBlur", 58 }); 59 60 const isBusy = loading || form.formState.isSubmitting || Boolean(submitted); 61 62 return ( 63 <Form {...form}> 64 <form 65 data-testid="password-forgot-form" 66 onSubmit={form.handleSubmit(onSubmit)} 67 > 68 <Card className="w-full rounded-md"> 69 <CardContent className="space-y-6 pt-1"> 70 <AccountIdField disabled={isBusy} /> 71 <EmailField disabled={isBusy} /> 72 <NoteField disabled={isBusy} /> 73 <FormDescription> 74 送信後、管理者より新規のパスワードが発行されます。メールで再発行通知があるまでしばらくお待ち下さい。 75 </FormDescription> 76 </CardContent> 77 78 <CardFooter className="mt-4 flex items-center gap-2"> 79 <Button 80 type="button" 81 variant="outline" 82 onClick={onCancel} 83 data-testid="cancel-btn" 84 className="cursor-pointer" 85 > 86 キャンセル 87 </Button> 88 89 {submitted ? ( 90 // ラベル表示(ボタンの代替) 91 <span 92 role="status" 93 aria-live="polite" 94 className="bg-muted text-foreground inline-flex items-center rounded-md px-2 py-1 font-medium" 95 data-testid="submitted-label" 96 > 97 再発行依頼完了 98 </span> 99 ) : ( 100 <Button 101 type="submit" 102 data-testid="submit-forgot" 103 className="cursor-pointer" 104 disabled={isBusy} 105 > 106 {isBusy ? "送信中..." : "依頼を送信"} 107 </Button> 108 )} 109 </CardFooter> 110 </Card> 111 </form> 112 </Form> 113 ); 114} 115 116/* ========================= 117 小さなフィールド群 118 ========================= */ 119function AccountIdField({ disabled }: { disabled: boolean }) { 120 return ( 121 <FormField 122 name="accountId" 123 render={({ field }) => ( 124 <FormItem> 125 <FormLabel className="font-semibold">アカウントID *</FormLabel> 126 <FormControl> 127 <Input 128 {...field} 129 disabled={disabled} 130 inputMode="text" 131 placeholder="例: COMPANY012345678" 132 autoComplete="username" 133 aria-label="アカウントID" 134 data-testid="accountId" 135 /> 136 </FormControl> 137 <FormMessage data-testid="accountId-error" /> 138 </FormItem> 139 )} 140 /> 141 ); 142} 143 144function EmailField({ disabled }: { disabled: boolean }) { 145 return ( 146 <FormField 147 name="email" 148 render={({ field }) => ( 149 <FormItem> 150 <FormLabel className="font-semibold">メールアドレス *</FormLabel> 151 <FormControl> 152 <Input 153 {...field} 154 disabled={disabled} 155 type="email" 156 placeholder="you@example.com" 157 autoComplete="email" 158 aria-label="メールアドレス" 159 data-testid="email" 160 /> 161 </FormControl> 162 <FormMessage data-testid="email-error" /> 163 </FormItem> 164 )} 165 /> 166 ); 167} 168 169function NoteField({ disabled }: { disabled: boolean }) { 170 return ( 171 <FormField 172 name="note" 173 render={({ field }) => ( 174 <FormItem> 175 <FormLabel className="font-semibold">備考(任意)</FormLabel> 176 <FormControl> 177 <Input 178 {...field} 179 disabled={disabled} 180 placeholder="依頼の背景や担当者名など" 181 inputMode="text" 182 autoComplete="off" 183 aria-label="備考" 184 data-testid="note" 185 /> 186 </FormControl> 187 <FormMessage data-testid="note-error" /> 188 </FormItem> 189 )} 190 /> 191 ); 192}
  • 依頼フォーム側では バリデーション定義を一切複製せずaccountIdSchemaemailSchema合成しています。
  • UI構成・命名・data-testid 付与などは users/user-form.tsx に寄せ、プロジェクト内の統一感を保っています。
  • onCancel をオプションにしておくと、呼び出し側(client.tsx)で history.back() など柔軟に扱えます。
  • submitted親から受け取る制御プロップ とし、入力欄の disabled とフッターの表示切替を一括制御します。
  • ボタンは 依頼済みラベル に置換。aria-live="polite" を付与し、支援技術にも分かりやすくしています。
  • isBusyloading / submitting / submitted を合算して無効化を一本化しました。

クライアント(送信ハンドラ): src/app/(public)/password-forgot/client.tsx

UIのみ のため、バリデーション通過後は OK としてトースト表示します。
将来は Server Action に差し替える想定で、送信関数の入口を1か所に集約します。
tsx
1// src/app/(public)/password-forgot/client.tsx 2"use client"; 3 4import { useState } from "react"; 5import { toast } from "sonner"; 6import PasswordForgotForm from "@/components/login/password-forgot-form"; 7 8export default function PasswordForgotClient() { 9 const [loading, setLoading] = useState(false); 10 const [submitted, setSubmitted] = useState(false); 11 12 const handleSubmit = async () => { 13 if (submitted) return; // 二重送信ガード(念のため) 14 setLoading(true); 15 try { 16 // 将来: Server Action に置換 17 await new Promise((r) => setTimeout(r, 700)); 18 setSubmitted(true); 19 toast.success("依頼を受け付けました。登録メールをご確認ください。"); 20 } catch { 21 // エラー時も秘匿(受付メッセージは同一) 22 setSubmitted(true); 23 toast.success("依頼を受け付けました。登録メールをご確認ください。"); 24 } finally { 25 setLoading(false); 26 } 27 }; 28 29 return ( 30 <PasswordForgotForm 31 onSubmit={handleSubmit} 32 loading={loading} 33 submitted={submitted} 34 onCancel={() => history.back()} 35 /> 36 ); 37}
  • 例外時でも 同じメッセージ を返し、アカウント/メールの有無を 推測されない ようにします。
  • onCancel は単純に history.back()

ページ骨格: src/app/(public)/password-forgot/page.tsx

次にページ枠を用意して、中央配置+見出しを整えます。
tsx
1// src/app/(public)/password-forgot/page.tsx 2import type { Metadata } from "next"; 3import Client from "./client"; 4import Image from "next/image"; 5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 6 7export const metadata: Metadata = { 8 title: "パスワード再発行依頼", 9}; 10 11export default function PasswordForgotPage() { 12 return ( 13 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800"> 14 <Card className="w-full max-w-md"> 15 <CardHeader> 16 <CardTitle className="flex flex-col justify-center gap-4"> 17 {/* light用ロゴ(=ダークモード時に非表示) */} 18 <Image 19 src="/logo.svg" 20 alt="サイトロゴ" 21 width={160} 22 height={40} 23 priority 24 className="h-[40px] w-[160px] dark:hidden" 25 /> 26 27 {/* dark用ロゴ(=ダークモード時に表示) */} 28 <Image 29 src="/logo-d.svg" 30 alt="サイトロゴ(ダーク)" 31 width={160} 32 height={40} 33 priority 34 className="hidden h-[40px] w-[160px] dark:block" 35 /> 36 37 <h1 className="text-2xl font-bold">パスワード再発行の依頼</h1> 38 <p className="text-muted-foreground mt-1 text-left"> 39 アカウントIDとメールアドレスを入力して、管理者に再発行を依頼します。 40 </p> 41 </CardTitle> 42 </CardHeader> 43 <CardContent> 44 <Client /> 45 </CardContent> 46 </Card> 47 </main> 48 ); 49}
ページ自体は薄く保ち、ロジックは client.tsx、UI/バリデーションは components/ へ寄せました。
この分離により、Server Action 置換点は client.tsx のみ に集約され、保守性が上がります。

src/app/(public)配下の共通レイアウト

次に、src/app/(public)配下でも共通でトースト通知を有効にするために、src/app/(public)/layout.tsxを作成します。
tsx
1// src/app/(public)/layout.tsx 2import type { Metadata } from "next"; 3import { Toaster } from "@/components/ui/sonner"; 4 5export const metadata: Metadata = { 6 title: "管理画面 | DELOGs", 7 description: "ログイン前の共通レイアウト", 8}; 9 10export default function PubulicLayout({ 11 children, 12}: { 13 children: React.ReactNode; 14}) { 15 return ( 16 <> 17 {children} 18 <Toaster richColors closeButton /> 19 </> 20 ); 21}
本当にトースト通知を有効にするだけの共通レイアウトです。

ログイン画面にパスワード忘れの導線設置

最後にsrc/app/page.tsxに導線を設置します。
tsx
1// src/app/page.tsx 2import Image from "next/image"; 3import Link from "next/link"; //追加 4import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 5import LoginForm from "@/components/login/login-form"; // ログインフォームコンポーネント 6import { HandHelping } from "lucide-react"; //追加 7 8export default function Page() { 9 return ( 10 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800"> 11 <Card className="w-full max-w-md"> 12 <CardHeader> 13 <CardTitle className="flex justify-center"> 14 {/* light用ロゴ(=ダークモード時に非表示) */} 15 <Image 16 src="/logo.svg" 17 alt="サイトロゴ" 18 width={160} 19 height={40} 20 priority 21 className="h-[40px] w-[160px] dark:hidden" 22 /> 23 24 {/* dark用ロゴ(=ダークモード時に表示) */} 25 <Image 26 src="/logo-d.svg" 27 alt="サイトロゴ(ダーク)" 28 width={160} 29 height={40} 30 priority 31 className="hidden h-[40px] w-[160px] dark:block" 32 /> 33 </CardTitle> 34 </CardHeader> 35 <CardContent> 36 <LoginForm /> 37 {/* 追加: パスワード忘れの導線 */} 38 <Link 39 href="/password-forgot" 40 className="my-2 ml-auto flex items-center justify-end gap-2 text-sm" 41 > 42 パスワードをお忘れの方 43 <HandHelping /> 44 </Link> 45 </CardContent> 46 </Card> 47 </main> 48 ); 49}
  • next/linkとルシードアイコンを追加
  • /password-forgotへの導線を設置
▼ ログイン画面
パスワード忘れ導線設置後のログイン画面
▼ パスワード再発行依頼画面
パスワード再発行依頼画面
以上で、パスワード忘れ導線のUIが完成しました。次章では、管理者側のUIを作成していきます。

5. 管理者側の依頼一覧と再発行UI

この章では、一覧テーブルだけで「再発行」まで完結する管理者UIを実装します。
実装順は下層から上層へ(依存エラーを避けるため) mock → columns.tsx → data-table.tsx → page.tsx の順で進めます。
検索・フィルタは、既存の「ユーザ一覧(/users)」と同じ体験に揃えます。
具体的には、キーワード検索accountId / email / userName / note)と、ロール絞り込み状態絞り込み(未処理/再発行済み) を提供します。
補足:日付の表示整形には 日付処理ライブラリ date-fns を使います(後述のコードで format / ja を import)。

モック + 型定義(src/lib/users/password-request.mock.ts

一覧で使う データ型・モック・ユーティリティ を1ファイルに集約します。
mockUsers を参照して ユーザ名/ロール を解決し、見つからない場合は - を表示にします。
また、UIから呼び出す listPasswordRequests / markIssued を用意し、将来は Server Action に置き換えます。
ts
1// src/lib/users/password-request.mock.ts 2 3import { z } from "zod"; 4import { mockUsers, CURRENT_ACCOUNT_CODE } from "@/lib/users/mock"; 5import type { RoleCode } from "@/lib/users/schema"; 6 7/* ========================= 8 型定義 9 ========================= */ 10export const PasswordRequestStatus = z.enum(["PENDING", "ISSUED", "REJECTED"]); 11export type PasswordRequestStatus = z.infer<typeof PasswordRequestStatus>; 12 13export type PasswordRequest = { 14 id: string; 15 accountId: string; 16 email: string; 17 note?: string; 18 status: PasswordRequestStatus; 19 requestedAt: Date; 20 processedAt?: Date; 21 processedBy?: string; 22 23 userName: string; 24 userRole: RoleCode | "-"; 25}; 26 27function resolveUser(accountId: string, email: string) { 28 const u = mockUsers.find( 29 (x) => 30 x.accountCode === accountId && 31 x.email.toLowerCase() === email.toLowerCase(), 32 ); 33 return u 34 ? { userName: u.name, userRole: u.roleCode as RoleCode } 35 : { userName: "-", userRole: "-" as const }; 36} 37 38/* モックデータ */ 39export const passwordRequests: PasswordRequest[] = [ 40 { 41 id: "PR000001", 42 accountId: CURRENT_ACCOUNT_CODE, 43 email: "admin@example.com", 44 note: "本人からの電話申請", 45 status: "PENDING", 46 requestedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), 47 ...resolveUser(CURRENT_ACCOUNT_CODE, "admin@example.com"), 48 }, 49 { 50 id: "PR000002", 51 accountId: CURRENT_ACCOUNT_CODE, 52 email: "editor@example.com", 53 note: "", 54 status: "ISSUED", 55 requestedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), 56 processedAt: new Date(Date.now() - 1000 * 60 * 60 * 24), 57 processedBy: "Admin User", 58 ...resolveUser(CURRENT_ACCOUNT_CODE, "editor@example.com"), 59 }, 60]; 61 62export function listPasswordRequests(accountId: string): PasswordRequest[] { 63 return passwordRequests.filter((r) => r.accountId === accountId); 64} 65 66export function markIssued( 67 id: string, 68 processedBy: string, 69): PasswordRequest | undefined { 70 const r = passwordRequests.find((x) => x.id === id); 71 if (!r) return undefined; 72 if (r.status !== "PENDING") return r; 73 r.status = "ISSUED"; 74 r.processedAt = new Date(); 75 r.processedBy = processedBy; 76 return r; 77} 78 79export function markRejected( 80 id: string, 81 processedBy: string, 82): PasswordRequest | undefined { 83 const r = passwordRequests.find((x) => x.id === id); 84 if (!r) return undefined; 85 if (r.status !== "PENDING") return r; 86 r.status = "REJECTED"; 87 r.processedAt = new Date(); 88 r.processedBy = processedBy; 89 return r; 90}
  • PasswordRequestuserName / userRole を含め、テーブル側での join を避けて描画を簡潔にしました。
  • 照合できない場合でも表示が崩れないよう "-" を返します。
  • UI専用APIlistPasswordRequests/markIssued/markRejected)は、将来 Server Action に置き換えるだけで移行できます。

一覧用のデータテーブルの型拡張

前回記事 管理画面フォーマット作成編 #7 サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで でもやったように、今回もsrc/types/table-meta.d.tsで型拡張を行います。今回は依頼への対応実行するハンドラを追加します。
ts
1// src/types/table-meta.d.ts 2import "@tanstack/table-core"; 3 4declare module "@tanstack/table-core" { 5 interface TableMeta<TData extends RowData> { 6 onMoveUp?: (id: string, _row?: TData) => void; 7 onMoveDown?: (id: string, _row?: TData) => void; 8 9 /** 依頼を「再発行済み」にする */ 10 onIssue?: (id: string, _row?: TData) => void | Promise<void>; 11 /** 依頼を「拒否」にする */ 12 onReject?: (id: string, _row?: TData) => void | Promise<void>; 13 } 14} 15 16export {};
onIssueonRejectを追加しました。

日付処理ライブラリ date-fns を導入

今回まだDate Pickerは利用しませんが、列定義の中で日付処理を行いますので、事前にdate-fnsを導入します。
zsh
1npm install date-fns

列定義(src/app/(protected)/users/password-request/columns.tsx

「ユーザ一覧」と同じトーン&マナーで列定義を行います。
ロールは getRoles() を使って 表示名/色 を取得し、Badge で強調します。
重要ポイントcolumns「関数ではなく定数」 としてエクスポートし、
行アクションのクリックは TanStack Table の table.options.meta.onIssue 経由で呼び出します。
(これにより、Server から Client 関数を直接実行しない ので RSC 境界エラーを回避できます)
tsx
1// src/app/(protected)/users/password-request/columns.tsx 2"use client"; 3 4import type { ColumnDef } from "@tanstack/react-table"; 5import { format } from "date-fns"; 6import { ja } from "date-fns/locale"; 7import { Badge } from "@/components/ui/badge"; 8import { Button } from "@/components/ui/button"; 9import type { PasswordRequest } from "@/lib/users/password-request.mock"; 10import { getRoles } from "@/lib/roles/mock"; 11 12function fmt(d?: Date) { 13 if (!d) return "-"; 14 return format(d, "yyyy/MM/dd HH:mm", { locale: ja }); 15} 16 17const roleInfoMap: Record<string, { label: string; color: string }> = 18 Object.fromEntries( 19 getRoles().map((r) => [ 20 r.code, 21 { label: r.displayName, color: r.badgeColor }, 22 ]), 23 ); 24 25export const columns: ColumnDef<PasswordRequest>[] = [ 26 { 27 accessorKey: "requestedAt", 28 header: "依頼日時", 29 cell: ({ row }) => fmt(row.original.requestedAt), 30 }, 31 { accessorKey: "accountId", header: "アカウントID" }, 32 { accessorKey: "email", header: "メール" }, 33 { accessorKey: "userName", header: "ユーザ名" }, 34 { 35 accessorKey: "userRole", 36 header: "ユーザロール", 37 cell: ({ row }) => { 38 const role = row.original.userRole; 39 if (role === "-") return "-"; 40 const info = roleInfoMap[role]; 41 const label = info?.label ?? role; 42 const style = info 43 ? { backgroundColor: info.color, color: "#fff", border: "none" } 44 : undefined; 45 return ( 46 <Badge variant="secondary" style={style} title={role}> 47 {label} 48 </Badge> 49 ); 50 }, 51 }, 52 { 53 accessorKey: "status", 54 header: "状態", 55 cell: ({ row }) => { 56 const s = row.original.status; 57 if (s === "ISSUED") return <Badge>再発行済み</Badge>; 58 if (s === "REJECTED") return <Badge variant="destructive">拒否</Badge>; 59 return <Badge variant="outline">未処理</Badge>; 60 }, 61 }, 62 { 63 accessorKey: "processedAt", 64 header: "処理日時", 65 cell: ({ row }) => fmt(row.original.processedAt), 66 }, 67 { accessorKey: "processedBy", header: "処理者" }, 68 69 { 70 id: "actions", 71 header: "操作", 72 enableSorting: false, 73 enableResizing: false, 74 cell: ({ row, table }) => { 75 const r = row.original; 76 const disabled = r.status !== "PENDING"; // 処理済みは操作不可 77 return ( 78 <div className="flex gap-2"> 79 <Button 80 size="sm" 81 disabled={disabled} 82 onClick={() => table.options.meta?.onIssue?.(r.id, r)} 83 data-testid={`issue-btn-${r.id}`} 84 className="cursor-pointer" 85 > 86 再発行 87 </Button> 88 <Button 89 size="sm" 90 variant="outline" 91 disabled={disabled} 92 onClick={() => table.options.meta?.onReject?.(r.id, r)} 93 data-testid={`reject-btn-${r.id}`} 94 className="cursor-pointer" 95 > 96 拒否 97 </Button> 98 </div> 99 ); 100 }, 101 }, 102 103 // 検索用 hidden 列 104 { 105 id: "q", 106 accessorFn: (row) => 107 `${row.accountId} ${row.email} ${row.userName} ${row.note ?? ""}`.toLowerCase(), 108 enableHiding: true, 109 enableSorting: false, 110 header: () => null, 111 cell: () => null, 112 }, 113];
  • 関数ではなく定数 columns をエクスポートすることで、Server 側から「実行」されず、RSC 境界の問題を避けられます。
  • 行アクションは table.options.meta.onIssue に委譲し、クリック時のみ クライアント関数を呼びます。
  • 日付整形は date-fns を使用(yyyy/MM/dd HH:mm)。ロケールは ja を指定しています。
  • 再発行拒否を実行できるようにしています。

テーブル本体(src/app/(protected)/users/password-request/data-table.tsx

ユーザ一覧の data-table.tsx を踏襲し、
キーワード検索/ロール絞り込み/状態絞り込みページング を提供します。
ここで meta.onIssue を定義し、列定義(actionsセル)から呼べるようにします。
また、行の状態を即時反映するため、一覧データは ローカル状態に保持 して更新します。
tsx
1// src/app/(protected)/users/password-request/data-table.tsx 2"use client"; 3 4import * as React from "react"; 5import type { ColumnDef, SortingState } from "@tanstack/react-table"; 6import { 7 flexRender, 8 getCoreRowModel, 9 getPaginationRowModel, 10 getSortedRowModel, 11 useReactTable, 12} from "@tanstack/react-table"; 13import { Input } from "@/components/ui/input"; 14import { 15 Select, 16 SelectContent, 17 SelectItem, 18 SelectTrigger, 19 SelectValue, 20} from "@/components/ui/select"; 21import { 22 Table, 23 TableBody, 24 TableCell, 25 TableHead, 26 TableHeader, 27 TableRow, 28} from "@/components/ui/table"; 29import { Button } from "@/components/ui/button"; 30import { toast } from "sonner"; 31 32import type { PasswordRequest } from "@/lib/users/password-request.mock"; 33import { markIssued, markRejected } from "@/lib/users/password-request.mock"; 34import type { RoleOption } from "@/lib/users/mock"; 35 36type ReqStatusFilter = "ALL" | "PENDING" | "ISSUED" | "REJECTED"; 37type RoleFilter = "ALL" | string; 38 39type Props<TData> = { 40 columns: ColumnDef<TData, unknown>[]; 41 data: TData[]; 42 roleOptions: RoleOption[]; 43}; 44 45export default function DataTable<TData extends PasswordRequest>({ 46 columns, 47 data, 48 roleOptions, 49}: Props<TData>) { 50 const [tableData, setTableData] = React.useState<PasswordRequest[]>( 51 () => data as PasswordRequest[], 52 ); 53 54 const [q, setQ] = React.useState(""); 55 const [role, setRole] = React.useState<RoleFilter>("ALL"); 56 const [status, setStatus] = React.useState<ReqStatusFilter>("ALL"); 57 const [sorting, setSorting] = React.useState<SortingState>([ 58 { id: "requestedAt", desc: true }, 59 ]); 60 61 const filteredData = React.useMemo(() => { 62 const needle = q.trim().toLowerCase(); 63 return tableData.filter((r) => { 64 const passQ = 65 !needle || 66 `${r.accountId} ${r.email} ${r.userName} ${r.note ?? ""}` 67 .toLowerCase() 68 .includes(needle); 69 const passRole = role === "ALL" || r.userRole === role; 70 const passStatus = status === "ALL" || r.status === status; 71 return passQ && passRole && passStatus; 72 }) as unknown as TData[]; 73 }, [tableData, q, role, status]); 74 75 const onIssue = React.useCallback( 76 async (id: string) => { 77 // 将来: Server Action に置換 78 markIssued(id, "Admin User"); 79 setTableData((prev) => 80 prev.map((r) => 81 r.id === id 82 ? { 83 ...r, 84 status: "ISSUED", 85 processedAt: new Date(), 86 processedBy: "Admin User", 87 } 88 : r, 89 ), 90 ); 91 toast.success("再発行を受け付けました。", { duration: 2400 }); 92 }, 93 [setTableData], 94 ); 95 96 const onReject = React.useCallback( 97 async (id: string) => { 98 // 将来: Server Action に置換(理由入力などは別UIで拡張可能) 99 markRejected(id, "Admin User"); 100 setTableData((prev) => 101 prev.map((r) => 102 r.id === id 103 ? { 104 ...r, 105 status: "REJECTED", 106 processedAt: new Date(), 107 processedBy: "Admin User", 108 } 109 : r, 110 ), 111 ); 112 toast.message("依頼を拒否しました。", { duration: 2400 }); 113 }, 114 [setTableData], 115 ); 116 117 const table = useReactTable({ 118 data: filteredData, 119 columns, 120 state: { sorting }, 121 onSortingChange: setSorting, 122 getCoreRowModel: getCoreRowModel(), 123 getSortedRowModel: getSortedRowModel(), 124 getPaginationRowModel: getPaginationRowModel(), 125 initialState: { pagination: { pageIndex: 0, pageSize: 10 } }, 126 meta: { onIssue, onReject }, // ← 追加 127 }); 128 129 const filteredCount = filteredData.length; 130 131 return ( 132 <div className="space-y-3"> 133 {/* 検索/フィルタ */} 134 <div className="flex flex-wrap items-center gap-3"> 135 <Input 136 name="filter-q" 137 data-testid="filter-q" 138 value={q} 139 onChange={(e) => setQ(e.target.value)} 140 placeholder="アカウントID・メール・氏名・備考で検索" 141 className="w-[220px] basis-full text-sm md:basis-auto" 142 aria-label="検索キーワード" 143 /> 144 145 <Select 146 value={role} 147 onValueChange={(v) => setRole(v as RoleFilter)} 148 name="filter-role" 149 > 150 <SelectTrigger className="w-auto" data-testid="filter-role"> 151 <SelectValue placeholder="ロール" /> 152 </SelectTrigger> 153 <SelectContent className="text-xs"> 154 <SelectItem value="ALL"> 155 <span className="text-muted-foreground">すべてのロール</span> 156 </SelectItem> 157 {roleOptions.map((o) => ( 158 <SelectItem key={o.value} value={o.value}> 159 {o.label} 160 </SelectItem> 161 ))} 162 </SelectContent> 163 </Select> 164 165 <Select 166 value={status} 167 onValueChange={(v) => setStatus(v as ReqStatusFilter)} 168 name="filter-status" 169 > 170 <SelectTrigger className="w-auto" data-testid="filter-status"> 171 <SelectValue placeholder="状態" /> 172 </SelectTrigger> 173 <SelectContent> 174 <SelectItem value="ALL"> 175 <span className="text-muted-foreground">すべての状態</span> 176 </SelectItem> 177 <SelectItem value="PENDING">未処理のみ</SelectItem> 178 <SelectItem value="ISSUED">再発行済みのみ</SelectItem> 179 <SelectItem value="REJECTED">拒否のみ</SelectItem> 180 </SelectContent> 181 </Select> 182 </div> 183 184 <div className="flex items-center justify-between"> 185 <div className="text-sm" data-testid="count"> 186 表示件数: {filteredCount}187 </div> 188 </div> 189 190 {/* テーブル */} 191 <div className="overflow-x-auto rounded-md border pb-1"> 192 <Table data-testid="requests-table" className="w-full"> 193 <TableHeader className="bg-muted/50 text-xs"> 194 {table.getHeaderGroups().map((hg) => ( 195 <TableRow key={hg.id}> 196 {hg.headers.map((header) => ( 197 <TableHead 198 key={header.id} 199 style={{ width: header.column.getSize() }} 200 > 201 {header.isPlaceholder 202 ? null 203 : flexRender( 204 header.column.columnDef.header, 205 header.getContext(), 206 )} 207 </TableHead> 208 ))} 209 </TableRow> 210 ))} 211 </TableHeader> 212 <TableBody> 213 {table.getRowModel().rows.length ? ( 214 table.getRowModel().rows.map((row) => ( 215 <TableRow 216 key={row.id} 217 data-testid={`row-${(row.original as PasswordRequest).id}`} 218 > 219 {row.getVisibleCells().map((cell) => ( 220 <TableCell 221 key={cell.id} 222 style={{ width: cell.column.getSize() }} 223 > 224 {flexRender( 225 cell.column.columnDef.cell, 226 cell.getContext(), 227 )} 228 </TableCell> 229 ))} 230 </TableRow> 231 )) 232 ) : ( 233 <TableRow> 234 <TableCell 235 colSpan={columns.length} 236 className="text-muted-foreground py-10 text-center text-sm" 237 > 238 条件に一致する依頼が見つかりませんでした。 239 </TableCell> 240 </TableRow> 241 )} 242 </TableBody> 243 </Table> 244 </div> 245 246 {/* ページング */} 247 <div className="flex items-center justify-end gap-2"> 248 <span className="text-muted-foreground text-sm"> 249 Page {table.getState().pagination.pageIndex + 1} /{" "} 250 {table.getPageCount() || 1} 251 </span> 252 <Button 253 variant="outline" 254 size="sm" 255 onClick={() => table.previousPage()} 256 disabled={!table.getCanPreviousPage()} 257 data-testid="page-prev" 258 className="cursor-pointer" 259 > 260 前へ 261 </Button> 262 <Button 263 variant="outline" 264 size="sm" 265 onClick={() => table.nextPage()} 266 disabled={!table.getCanNextPage()} 267 data-testid="page-next" 268 className="cursor-pointer" 269 > 270 次へ 271 </Button> 272 </div> 273 </div> 274 ); 275}
  • metaonIssueonReject を渡し、列内ボタンから呼べるようにしました。
  • ハンドラは 即時にローカル状態を更新し、将来は Server Action へ置換可能です。
  • ページングや検索UIは、ユーザ一覧の書き方をそのまま踏襲しています。

ページ骨格(src/app/(protected)/users/password-request/page.tsx

page.tsxServer Component として、データ取得とレイアウトだけを担当します。
「ユーザ一覧」と同様に、columns 定数DataTable クライアントコンポーネントをそのまま渡す形にします。
tsx
1// src/app/(protected)/users/password-request/page.tsx 2import type { Metadata } from "next"; 3import { SidebarTrigger } from "@/components/ui/sidebar"; 4import { 5 Breadcrumb, 6 BreadcrumbItem, 7 BreadcrumbLink, 8 BreadcrumbList, 9 BreadcrumbPage, 10 BreadcrumbSeparator, 11} from "@/components/ui/breadcrumb"; 12import { Separator } from "@/components/ui/separator"; 13 14import DataTable from "./data-table"; 15import { columns } from "./columns"; 16 17import { CURRENT_ACCOUNT_CODE, mockRoleOptions } from "@/lib/users/mock"; 18import { listPasswordRequests } from "@/lib/users/password-request.mock"; 19 20export const metadata: Metadata = { 21 title: "パスワード再発行依頼 | 管理画面レイアウト【DELOGs】", 22 description: 23 "Data table(shadcn/ui + @tanstack/react-table)でパスワード再発行依頼を一覧表示", 24}; 25 26export default function Page() { 27 // UIのみ:ログイン中アカウント配下の依頼をモックから取得 28 const accountCode = CURRENT_ACCOUNT_CODE; 29 const rows = listPasswordRequests(accountCode); 30 31 return ( 32 <> 33 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 34 <div className="flex items-center gap-2 px-4"> 35 <SidebarTrigger className="-ml-1" /> 36 <Separator 37 orientation="vertical" 38 className="mr-2 data-[orientation=vertical]:h-4" 39 /> 40 <Breadcrumb> 41 <BreadcrumbList> 42 <BreadcrumbItem className="hidden md:block"> 43 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 44 </BreadcrumbItem> 45 <BreadcrumbSeparator className="hidden md:block" /> 46 <BreadcrumbItem> 47 <BreadcrumbPage>パスワード再発行依頼</BreadcrumbPage> 48 </BreadcrumbItem> 49 </BreadcrumbList> 50 </Breadcrumb> 51 </div> 52 </header> 53 54 <div className="max-w-full p-4 pt-0"> 55 <DataTable 56 columns={columns} 57 data={rows} 58 roleOptions={mockRoleOptions} 59 /> 60 </div> 61 </> 62 ); 63}
  • Server → Client 関数の直接呼び出しを行わない 構成です。
  • columns定数のため、Server 側から渡しても問題ありません。
  • 行アクションは data-table.tsx 内の meta.onIssue によって クライアント側で完結します。

メニューへ追加

前回記事で作成した src/lib/sidebar/menu.mock.tsに今回の「パスワード再発行」メニューを追記します。
ts
1// src/lib/sidebar/menu.mock.ts(抜粋) 2 { 3 displayId: "M00000016", 4 parentId: "M00000011", 5 order: 1, 6 title: "新規登録", 7 href: "/users/new", 8 iconName: undefined, 9 match: "exact", 10 pattern: undefined, 11 minPriority: undefined, 12 isSection: false, 13 isActive: true, 14 }, 15 // 追加 16 { 17 displayId: "M00000017", 18 parentId: "M00000011", 19 order: 1, 20 title: "パスワード再発行", 21 href: "/users/password-request", 22 iconName: undefined, 23 match: "exact", 24 pattern: undefined, 25 minPriority: undefined, 26 isSection: false, 27 isActive: true, 28 }, 29
INITIAL_MENU_RECORDS配列に上記の追記するだけで、サイドメニューの「ユーザ管理」に「パスワード再発行」が追加されて導線が設置されます。
以上で、本記事の目標は達成です。
▼ パスワード再発行画面
パスワード再発行画面

6. まとめと次回予告

本記事の振り返り

今回のゴールは「HTTPは正しく404を返しつつ、UIは独自デザイン」「パスワード忘れの自己リセットを避け、管理者フローに統一」の2点でした。App Router の notFound() とセグメント専用の not-found.tsx を組み合わせ、(protected) 内だけで統一の 404 体験を提供。あわせて、ログイン前の「再発行依頼フォーム」と、管理者向けの「依頼一覧+ワンクリック再発行/拒否」UIまでを、既存の設計・記法(page.tsx / client.tsx / data-table.tsx / columns.tsx)に沿って実装しました。

次回予告

「管理画面フォーマット制作編」の総仕上げとして、これまで作ってきた 共通レイアウト/ナビゲーション/フォーム基盤/一覧テーブル構成 を総括し、デモ を公開する予定です。

参考文献

Githubリポジトリ

この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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