DELOGs
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示

管理画面フォーマット開発編 #4
Server Actionで実装するアバター画像のアップロードと表示

ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築

初回公開日

最終更新日

0. はじめに

これまで「管理画面フォーマット開発編」シリーズでは、ログイン機能とコンテキストを利用したユーザ情報の表示までを整えました。
今回のテーマは、ユーザプロフィールに欠かせない アバター画像のアップロードと表示 です。単純に「画像を保存して表示する」だけであれば難しくありませんが、本プロジェクトでは以下の要件を満たしたいと考えています。
  • 画像は外部から直接アクセスできないよう保護すること
  • 必ず認証・認可を通じて配信されること
  • 将来的な拡張(CDNや外部ストレージ利用)にも対応できる設計であること
特に「画像を静的ファイルとして公開する」のではなく、Server Action を基盤にした仕組み でアップロードから配信までを扱う点が重要です。これにより、セッション情報やRBACロジックを統一的に適用でき、APIを最小化してセキュリティリスクを減らすことができます。
本記事では、Prismaのスキーマ拡張による User テーブルへの avatar カラム追加から、サーバ上への保存処理、Shadcn/ui を利用したフォーム実装まで、アバター機能を安全に構築する流れを整理します。シリーズを通じて積み上げてきた 認証・認可の基盤 と一体化させることで、現場でも安心して利用できるユーザプロフィール管理を完成させていきます。

技術スタック

Tool / LibVersionPurpose
Ubuntu24.04 LTSWebサーバOS
Nginx1.28.xWebサーバ
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #3】AuthProviderでログイン済みユーザー情報を全体共有 までのソースコードを引き継いで追加・編集していきます。

1. アバター画像機能の要件整理

アバター画像は「プロフィール編集の見た目を整える」だけではなく、セキュリティや将来の拡張性にも関わる要素です。ここでは、システムに組み込む際に押さえておくべき要件を整理します。

認証必須の配信(Next.js経由・直リンク禁止)

アバター画像は /var/www/private/avatars/ に保存されますが、このディレクトリは外部公開しません。必ず Next.js の Route Handler を通じて、Cookieベースのセッション検証とRBACを経由したユーザーだけに配信します。
直リンクでアクセスできてしまうと、認証を回避して画像が閲覧されるリスクがあるため、この設計が不可欠です。
txt
1[ブラウザ] → [Next.js Route Handler] → [認可チェック] → [ファイル読込] → [レスポンス] 2 ↑ 直リンクは禁止(Nginx経由で弾く)

保存先と命名(/var/www/private/avatars/ + UUID.ext)

ファイル名は UUID+拡張子 とし、衝突や予測を避けます。拡張子は Content-Type 判定のため保持します。
保存ディレクトリも固定し、コード中でパスを組み立てやすくします。
txt
1例: /var/www/private/avatars/ 2 ├─ 550e8400-e29b-41d4-a716-446655440000.png 3 └─ 1c6f3b32-88b3-44b9-bc42-33d9a9c541a4.webp

APIは作らない(Server Actionとリソース配信ルートのみ)

今回の実装では API Route を新規に作りません。アップロードは Server Action、表示は Route Handler で完結させます。これによりエンドポイントの種類を最小化し、セキュリティリスクを減らします。

要件を整理した一覧

以下の表に、今回のアバター機能で必須とする要件をまとめます。
区分要件内容理由
配信Next.js 経由(Route Handler)認証・認可を統一的に適用するため
直リンク禁止(Nginxレベルで遮断)セッション検証を回避されるのを防ぐため
保存ディレクトリ/var/www/private/avatars/外部から直接参照できない安全なパス
命名方式UUID + 拡張子衝突防止・推測困難・Content-Type判定用
アップロードServer ActionAPIを増やさず認可チェックを一元管理
表示/avatar/[userId] ルート配信専用の仕組みに限定し安全に返却

2. Prismaスキーマへの拡張

アバター機能を扱うために、まずは User テーブルへ新しいカラムを追加します。ここでは、スキーマ修正と既存コードへの影響を整理し、必要な修正箇所を実際に示していきます。

User.avatar を追加する理由

アバター画像は各ユーザが 1 つだけ保持する想定です。そのため別テーブルに切り出さず、最小構成として User モデルに avatar カラムを追加します。
このカラムには 保存先ディレクトリに置かれたファイル名(UUID+拡張子) を格納し、実際の配信時に組み合わせて利用します。
txt
1Userテーブルのイメージ 2 3| id (UUID) | name | email | roleId | avatar | 4|-----------|----------|-------------------|--------|---------------------------------| 5| U001 | 山田 太郎 | taro@example.com | R001 | 550e8400-e29b-41d4.png | 6| U002 | 佐藤 花子 | hanako@example.jp | R002 | 1c6f3b32-88b3-44b9.webp |

Prismaスキーマの修正

prisma/schema.prismaUser モデルへ avatar カラムを追加します。
prisma
1model User { 2 id String @id @default(uuid()) 3 displayId String @unique @default(dbgenerated("generate_display_id('user_display_id_seq','US')")) @db.VarChar(10) 4 isActive Boolean @default(true) 5 createdAt DateTime @default(now()) @db.Timestamptz 6 updatedAt DateTime @updatedAt @db.Timestamptz 7 deletedAt DateTime? @db.Timestamptz 8 9 departmentId String 10 roleId String 11 email String 12 hashedPassword String 13 name String 14 phone String? 15 remarks String? 16 17 failedLoginCount Int @default(0) 18 lockedUntil DateTime? @db.Timestamptz 19 20 // ★ 新規追加 21 avatar String? // UUID+拡張子を格納 22 23 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 24 role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) 25 sessions Session[] @relation("UserSessions") 26 27 @@unique([departmentId, email]) 28 @@index([departmentId]) 29 @@index([roleId]) 30 @@index([isActive]) 31 @@index([createdAt]) 32}
この変更後は、必ずマイグレーションとクライアント生成を実行します。
zsh
1npx prisma migrate dev --name add_user_avatar 2npx prisma generate
ただし、本番環境では migrate dev ではなく migrate deploy を使うことを推奨します。 dev コマンドは破壊的な変更を検出したときに DB をリセットする場合があるため、本番では安全にマイグレーションを適用するために deploy を利用します。

既存コードへの影響と修正

User.avatar の追加に伴い、アプリ全体の型や処理に影響が及びます。以下に主な修正箇所を表形式で整理します。
ファイル修正内容
src/lib/auth/user-snapshot.tsavatarUrl を DB の avatar から組み立てる処理に変更
src/lib/auth/types.tsAuthUserSnapshot の avatarUrl 型は そのまま利用可能
src/components/sidebar/nav-user.tsxそのまま利用可能、テーブルに値があるときだけDB由来、なければモック値 (/user-avatar.png) を利用
src/components/profile/profile-form.tsx保存完了後に setUser() を呼び出して avatar を即時反映(次章以降で解説)

getUserSnapshot(src/lib/auth/user-snapshot.ts) の修正

これまで avatarUrl を常に null で返していましたが、DB の値を利用するよう変更します。
ts
1// src/lib/auth/user-snapshot.ts 2import { prisma } from "@/lib/database"; 3import type { AuthUserSnapshot } from "./types"; 4 5/** 6 * セッションで得られた userId から、Context 用の最小スナップショットを作る。 7 * PII を最小化し、重い JOIN は避け、必要な列だけ select する。 8 */ 9export async function getUserSnapshot( 10 userId: string, 11): Promise<AuthUserSnapshot | null> { 12 const user = await prisma.user.findUnique({ 13 where: { id: userId }, 14 select: { 15 id: true, 16 name: true, 17 email: true, 18 avatar: true, // ★ 追加 19 role: { 20 select: { code: true, priority: true }, 21 }, 22 }, 23 }); 24 25 if (!user || !user.role) return null; 26 27 return { 28 userId: user.id, 29 name: user.name, 30 email: user.email, 31 avatarUrl: user.avatar ? `/avatar/${user.id}` : null, // ★ DB由来に変更 32 roleCode: user.role.code, 33 rolePriority: user.role.priority, 34 }; 35}
この修正により、ログイン後の useAuth().user.avatarUrl がモック値ではなく、DBに保存されたアバター画像のURLを参照するようになります。以降の UI 実装では、この値をそのまま使えるようになります。

3. 画像アップロード処理(Server Action)

この章では、Server Action を用いてアバター画像をアップロードし、サーバに保存して User.avatar を更新する一連の処理を実装します。API Route は作らず、フォーム送信→サーバ側検証→ファイル保存→DB更新までを 1アクションで完結 させます。

フォームからの送信とクライアント側バリデーション

プロフィール編集フォームから、nameavatarFile(任意)を送信します。クライアントでは既に profileUpdateSchema により 拡張子・容量・推奨ピクセル をチェック済みですが、改ざん防止のためサーバ側でも必ず再検証 します。
txt
1[ProfileForm] 2 ├─ zodで拡張子/容量チェック(UI) 3 └─ formDataで送信(name, avatarFile?) 45[Server Action] 6 ├─ zod & MIME再検証(サーバ) 7 ├─ UUID生成 & /var/www/private/avatars/ へ保存 8 ├─ 旧ファイルがあれば削除 9 └─ prisma.user.update({ avatar: "<uuid>.<ext>" })

サーバ側バリデーション(MIME/容量/拡張子)

クライアント側の zod に加えて、サーバ側では MIME(Content-Type)と拡張子 をチェックします。
最小構成では File.type を参照し、さらに シグネチャ(magic bytes)簡易判定 を行うことで、ヘッダ偽装のリスクを抑えます(PNG/JPEG/WebP/GIF に対応)。

検証ルール一覧(サーバ側)

ルール内容
容量<= MAX_IMAGE_MB * 1024 * 1024
許可MIMEimage/png, image/jpeg, image/webp, image/gif
拡張子.png, `.jpg
シグネチャ代表的な先頭バイト列で簡易判定(PNG/JPEG/WebP/GIFに対応)
保存ファイル名crypto.randomUUID() + "." + ext
保存ディレクトリ/var/www/private/avatars/(Nginx直公開なし・Next.js経由で配信)

fs保存(UUID生成・旧ファイル削除・User.avatar更新)

保存先は /var/www/private/avatars/。ファイル名は UUID + 拡張子 とし、既存アバターが設定済みの場合は 新規保存成功後に旧ファイルを削除 します。
DB には ファイル名のみ (例:550e84...-440000.png)を保存します。
ts
1// src/app/_actions/profile-avatar.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { randomUUID } from "crypto"; 6import { writeFile, unlink, mkdir } from "node:fs/promises"; 7import { access, constants } from "node:fs"; 8import { join } from "node:path"; 9import { lookupSessionFromCookie } from "@/lib/auth/session"; 10import { profileUpdateSchema, MAX_IMAGE_MB } from "@/lib/users/schema"; 11 12const AVATAR_DIR = 13 process.env.AVATAR_DIR && process.env.AVATAR_DIR.trim() !== "" 14 ? process.env.AVATAR_DIR 15 : "/var/www/private/avatars"; 16 17type ActionResult = 18 | { ok: true } 19 | { ok: false; fieldErrors?: Record<string, string>; message?: string }; 20 21function getExtFromMime(mime: string): string | null { 22 switch (mime) { 23 case "image/png": 24 return "png"; 25 case "image/jpeg": 26 return "jpg"; 27 case "image/webp": 28 return "webp"; 29 case "image/gif": 30 return "gif"; 31 default: 32 return null; 33 } 34} 35 36// 簡易シグネチャ検証(magic bytes): PNG/JPEG/WebP/GIF 37function looksValidBySignature(buf: Buffer, mime: string): boolean { 38 if (mime === "image/png") { 39 // 89 50 4E 47 0D 0A 1A 0A 40 return ( 41 buf.length > 8 && 42 buf[0] === 0x89 && 43 buf[1] === 0x50 && 44 buf[2] === 0x4e && 45 buf[3] === 0x47 46 ); 47 } 48 if (mime === "image/jpeg") { 49 // FF D8 ... FF D9 50 return buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8; 51 } 52 if (mime === "image/webp") { 53 // "RIFF" .... "WEBP" 54 return ( 55 buf.length > 12 && 56 buf.subarray(0, 4).toString() === "RIFF" && 57 buf.subarray(8, 12).toString() === "WEBP" 58 ); 59 } 60 if (mime === "image/gif") { 61 // "GIF8" 62 return buf.length > 4 && buf.subarray(0, 4).toString() === "GIF8"; 63 } 64 return false; 65} 66 67async function ensureDir(dir: string) { 68 await mkdir(dir, { recursive: true }); 69} 70 71async function fileExists(path: string): Promise<boolean> { 72 return new Promise((resolve) => { 73 access(path, constants.F_OK, (err) => resolve(!err)); 74 }); 75} 76 77/** 78 * プロフィール更新(氏名+アバター画像) 79 * - APIは使わずServer Actionのみ 80 * - 画像はUUID名で保存し、User.avatarに「ファイル名」を保存 81 */ 82export async function updateProfileAction( 83 formData: FormData, 84): Promise<ActionResult> { 85 // 1) 認証 86 const session = await lookupSessionFromCookie(); 87 if (!session.ok) return { ok: false, message: "認証が必要です" }; 88 89 // 2) クライアント側と同様のzodスキーマで name / avatarFile を検証(UIの改ざん対策) 90 const name = String(formData.get("name") ?? ""); 91 const avatarFile = formData.get("avatarFile"); 92 const input = { 93 name, 94 avatarFile: avatarFile instanceof File ? avatarFile : undefined, 95 }; 96 97 const parsed = await (async () => { 98 // profileUpdateSchemaは File | undefined を許容 99 const r = profileUpdateSchema.safeParse(input); 100 return r; 101 })(); 102 103 if (!parsed.success) { 104 const fieldErrors: Record<string, string> = {}; 105 for (const issue of parsed.error.issues) { 106 const key = issue.path[0]?.toString() || "form"; 107 fieldErrors[key] = issue.message; 108 } 109 return { ok: false, fieldErrors }; 110 } 111 112 // 3) アバターがある場合はサーバ側でもMIME/容量/シグネチャを検証して保存 113 let newAvatarFileName: string | null = null; 114 let oldAvatarFileName: string | null = null; 115 116 if (parsed.data.avatarFile) { 117 const f = parsed.data.avatarFile; 118 const mime = f.type; 119 const ext = getExtFromMime(mime); 120 if (!ext) { 121 return { 122 ok: false, 123 fieldErrors: { avatarFile: "許可されていない画像形式です" }, 124 }; 125 } 126 127 const bytes = await f.arrayBuffer(); 128 const buf = Buffer.from(bytes); 129 130 // 容量の再チェック(サーバ側) 131 if (buf.byteLength > MAX_IMAGE_MB * 1024 * 1024) { 132 return { 133 ok: false, 134 fieldErrors: { 135 avatarFile: `画像サイズは ${MAX_IMAGE_MB}MB 以下にしてください`, 136 }, 137 }; 138 } 139 140 if (!looksValidBySignature(buf, mime)) { 141 return { 142 ok: false, 143 fieldErrors: { avatarFile: "画像ファイルの形式を確認できませんでした" }, 144 }; 145 } 146 147 // 4) 保存(UUID + 拡張子) 148 await ensureDir(AVATAR_DIR); 149 const uuid = randomUUID(); 150 const fileName = `${uuid}.${ext}`; 151 const absPath = join(AVATAR_DIR, fileName); 152 await writeFile(absPath, buf, { flag: "wx" }); // 上書き防止 153 154 newAvatarFileName = fileName; 155 } 156 157 // 5) DB更新(トランザクション) 158 await prisma.$transaction(async (tx) => { 159 // 旧ファイル名の取得(新規保存があるときのみ) 160 if (newAvatarFileName) { 161 const current = await tx.user.findUnique({ 162 where: { id: session.userId }, 163 select: { avatar: true }, 164 }); 165 oldAvatarFileName = current?.avatar ?? null; 166 } 167 168 await tx.user.update({ 169 where: { id: session.userId }, 170 data: { 171 name: parsed.data.name, 172 ...(newAvatarFileName ? { avatar: newAvatarFileName } : {}), 173 }, 174 }); 175 }); 176 177 // 6) 旧ファイルの削除(新ファイルのDB反映が済んでから) 178 if (oldAvatarFileName) { 179 const oldPath = join(AVATAR_DIR, oldAvatarFileName); 180 if (await fileExists(oldPath)) { 181 // エラーは握りつぶしてOK(ログへ) 182 await unlink(oldPath).catch(() => {}); 183 } 184 } 185 186 return { ok: true }; 187}
  • 認証: lookupSessionFromCookie() でCookie→JWT→Sessionを検証し、本人のみ更新を許可します。
  • 入力検証: UIと同じ profileUpdateSchema をサーバ側でも適用。さらにMIME・シグネチャで偽装を抑止します。
  • 保存: UUID + 拡張子 のファイル名で /var/www/private/avatars/ に保存(flag: "wx" で衝突回避)。
  • DB更新: 成功後に User.avatar を更新。前のファイル名はトランザクション外で削除します(整合性優先)。
  • 戻り値: UI側は { ok: true } を受けて useAuth().setUser() を呼ぶことでヘッダのアバターを即時反映(5章で実装)。

環境変数の設定

ローカル環境と本番環境のディレクトリを環境変数で設定しておきます。
env
1// .env.local 2AVATAR_DIR=./private/avatars
env
1// .env 2AVATAR_DIR=/var/www/private/avatars
それぞれ、設定したディレクトリを作成しておきます。 本番サーバの/var/www/private/avatars/Next.js(Nodeプロセス)から書き込み可能 にしておきます(例:chown node:node / chmod 750)。アプリをPM2で起動している場合、pm2 listで起動ユーザを調べて、ディレクトリの所有者にします。

運用メモ(権限・パーミッション)

  • Nginx などのWebサーバからは 直接配信しない 設定にします(ドキュメントルート外・locationで拒否)。
  • マイグレーションは開発で migrate dev、本番では migrate deploy を推奨(2章の注意書き参照)。

4. アバター配信ルート(非API)

この章では、APIを作らずに Next.js の Route Handler でアバター画像を配信する仕組みを実装します。画像は /var/www/private/avatars/ に保管され、直リンクでは参照できず 、必ずサーバ側の認証・認可を通ってから配信されます。

配信の方針(同一テナント内の認証閲覧を許可)

チャットやメンション一覧で他者のアバター表示が必要なため、配信ルートは 認証必須 のうえで 同一テナント(例:Department 単位)内のユーザーなら閲覧を許可します。
URLは /avatar/[userId] で、未認証は 401、テナント外は 403 を返し、直リンクでの一般公開は行いません。
txt
1認可ルール v1(最小構成) 2 3| 条件 | 結果 | 4|-------------------------------|--------| 5| 未認証 | 401 | 6| 認証済み & 同一Department | 200 | 7| 認証済み & 異なるDepartment | 403 | 8| 対象ユーザー/ファイル不在 | 404 |

Route Handler の実装(同一Department内の閲覧許可)

session.userId の所属と、params.userId の所属を突き合わせ、同一 departmentId のときだけ画像を返します。
将来的に「Account単位で可」「ROLE=ADMINは跨ぎ可」などへ拡張する余地を残しつつ、まずは最小ルールで運用します。
ts
1// src/app/(protected)/avatar/[userId]/route.ts 2import { prisma } from "@/lib/database"; 3import { lookupSessionFromCookie } from "@/lib/auth/session"; 4import { readFile } from "node:fs/promises"; 5import { join } from "node:path"; 6 7const AVATAR_DIR = 8 process.env.AVATAR_DIR && process.env.AVATAR_DIR.trim() !== "" 9 ? process.env.AVATAR_DIR 10 : "/var/www/private/avatars"; 11 12// 画像シグネチャでMIME判定(簡易) 13function detectMime(buf: Buffer): string | null { 14 if ( 15 buf.length > 8 && 16 buf[0] === 0x89 && 17 buf[1] === 0x50 && 18 buf[2] === 0x4e && 19 buf[3] === 0x47 20 ) 21 return "image/png"; 22 if (buf.length > 2 && buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg"; 23 if ( 24 buf.length > 12 && 25 buf.subarray(0, 4).toString() === "RIFF" && 26 buf.subarray(8, 12).toString() === "WEBP" 27 ) 28 return "image/webp"; 29 if (buf.length > 4 && buf.subarray(0, 4).toString() === "GIF8") 30 return "image/gif"; 31 return null; 32} 33 34export async function GET( 35 _req: Request, 36 ctx: { params: Promise<{ userId: string }> }, // ★ 変更:Promise を受ける 37) { 38 // 1) 認証 39 const session = await lookupSessionFromCookie(); 40 if (!session.ok) return new Response("Unauthorized", { status: 401 }); 41 42 // 2) 動的パラメータを await で取得 43 const { userId: targetUserId } = await ctx.params; // ★ 変更:await 44 45 // 3) ビューア&ターゲットの所属を取得(同一Departmentのみ許可) 46 const [viewer, target] = await Promise.all([ 47 prisma.user.findUnique({ 48 where: { id: session.userId }, 49 select: { departmentId: true, isActive: true }, 50 }), 51 prisma.user.findUnique({ 52 where: { id: targetUserId }, 53 select: { departmentId: true, isActive: true, avatar: true }, 54 }), 55 ]); 56 57 if (!viewer?.isActive) return new Response("Forbidden", { status: 403 }); 58 if (!target?.isActive) return new Response("Not Found", { status: 404 }); // 秘匿 59 60 // 4) 認可:同一Departmentのみ 61 if (viewer.departmentId !== target.departmentId) { 62 return new Response("Forbidden", { status: 403 }); 63 } 64 65 // 5) ファイル解決 66 const fileName = target.avatar; 67 if (!fileName) return new Response("Not Found", { status: 404 }); 68 69 const absPath = join(AVATAR_DIR, fileName); 70 let buf: Buffer; 71 try { 72 buf = await readFile(absPath); 73 } catch { 74 return new Response("Not Found", { status: 404 }); 75 } 76 77 // 6) Content-Type 78 const mime = detectMime(buf) ?? "application/octet-stream"; 79 80 // 7) private 配信(キャッシュ不可) 81 const body = new Uint8Array(buf); // ← Buffer を Web 互換へ 82 return new Response(body, { 83 status: 200, 84 headers: { 85 "Content-Type": mime, 86 "Content-Length": String(body.byteLength), 87 "Cache-Control": "private, no-store", 88 "Content-Disposition": "inline", 89 }, 90 }); 91}
  • 同一テナント(Department)判定で「社内の人には見える」を満たしつつ、直リンク/匿名アクセスは防止。
  • ターゲットが無効化されている場合は 404 を返し、存在を秘匿(ユーザー列挙対策)。
  • 画像自体は 拡張子無しのUUID 名で保存し、配信時にmagic bytesで Content-Type を決定。
  • キャッシュは private, no-store。チャット等の即時反映を優先し、ブラウザ/中間キャッシュの保持を避けます。
補足:Server Actions / Route Handlers / API Routes の比較
App Router 時代の Next.js では、サーバ処理をどこに実装するか を理解しておくことが重要です。ここでは「Server Actions」「Route Handlers」「API Routes」を比較して整理します。
項目Server ActionsRoute HandlersAPI Routes (旧)
配置場所app/.../page.tsx 内や app/_actions/*.tsapp/.../route.tspages/api/*.ts
呼び出し方<form action={...}>useTransition から直呼び出しfetch や URL アクセスで叩くfetch や URL アクセスで叩く
URL の有無持たない(内部的に Next.js が扱う)持つ/avatar/[userId] など直接アクセス可能)持つ/api/... プレフィックス付き)
主な用途フォーム送信、DB更新、認可チェックなど画像配信、Webhook受信、外部公開エンドポイントPages Router 時代のAPI実装
利用できるAPI直接 DB や Server API にアクセスWeb標準の Request / ResponseNode/Express風の req / res
認証・認可の必須度ユーザー操作起点なので漏洩しにくいURL直叩き可能なので ガード必須URL直叩き可能なので ガード必須
App Router での推奨度✅ 推奨✅ 推奨⚠️ Pages Router専用(レガシー)

(必要に応じて)認可スコープの拡張案

将来の要件に合わせて、以下のように1行ずつ拡張できます。
要件認可条件の例(書き換え先)
Account単位で可視(部署またぎ)viewer.accountId === target.accountId(JOINで取得)
管理者は全ユーザー閲覧可if (viewer.role.priority >= 100) allow
招待制のルーム単位で可視ルーム参加テーブルをJOINして exists チェック
本記事では Department一致 を最小ルールとして先に進みます。

middleware.tsへの追加

middleware.tsへも/avatarを追加します。これで、認証前の段階でも有効トークンの存在チェックで弾けるようになります。
ts
1// middleware.ts(抜粋) 2import { NextResponse } from "next/server"; 3import type { NextRequest } from "next/server"; 4import { verifySessionJwt } from "@/lib/auth/jwt"; 5 6const LOGIN_PATH = "/"; 7 8// 実際の公開パス((protected)配下の各ルートのパスを列挙) 9const PROTECTED_PATHS = [ 10 "/changelog", 11 "/dashboard", 12 "/masters", 13 "/tutorial", 14 "/users", 15 "/profile", 16 "/avatar", //追加 17]; 18// 実際の公開パス((protected)配下の各ルートのパスを列挙) 19const PROTECTED_PATHS = [ 20 "/changelog", 21 "/dashboard", 22 "/masters", 23 "/tutorial", 24 "/users", 25 "/profile", 26 "/avatar", //追加 27]; 28 29// ── 省略 30 31export const config = { 32 matcher: [ 33 "/changelog/:path*", 34 "/dashboard/:path*", 35 "/masters/:path*", 36 "/tutorial/:path*", 37 "/users/:path*", 38 "/profile/:path*", 39 "/avatar/:path*", //追加 40 ], 41};

5. フロント実装:フォーム送信と即時反映

この章では、Server Action での保存(3章)Route Handlerでの配信(4章) を受けて、プロフィール画面からアバターをアップロードし、保存直後にヘッダのアバターも即時に更新される一連の UI を実装します。
ポイントは次の3つです。
  • フォームから updateProfileAction を呼び出す(サーバで保存 & User.avatar 更新)
  • 保存直後に 最新のユーザースナップショット を Server Action で再取得して AuthContext に反映
  • 反映後、ヘッダ(<NavUser />)のアバターが即座に入れ替わる(ページ遷移なし)
txt
1# 構成とデータフロー(5章の最小構成) 2 3[ProfileForm] --(FormData: name, avatarFile)--> [updateProfileAction] --(保存OK)--> 4 --(refreshAuthSnapshotAction)--> { user: AuthUserSnapshot } --> [AuthContext.setUser()] 5 | 6 v 7 <NavUser /> の表示更新

UIからサーバまでの流れ

ここで扱う主要な部品と役割を表にまとめます。
レイヤファイル/関数役割
Server ActionupdateProfileAction(3章実装済み)画像の検証・保存、User.avatar の更新
Server Action(新規)refreshAuthSnapshotAction(本章で追加)最新の AuthUserSnapshot を取得して返却
Route Handler/avatar/[userId](4章実装済み)認証・認可を通じて画像バイトを返却
ContextAuthContext.setUser()アプリ全体にユーザー情報を即時配信
Client(protected)/profile/client.tsxフォーム送信・トースト・スナップショット再取得・setUser 呼び出し
補足(キャッシュ更新)
/avatar/[userId]Cache-Control: private, no-store を返すため、通常は再読み込みで十分です。
ただし、同一タブで連続更新の即時反映をより確実にするため、クライアント側で取得直後のみ ?ts=... のような一時的なクエリを付与して ワンショットでキャッシュバイパス するのが実務的です(本章の実装に含めます)。

スナップショット再取得の Server Action を追加

アバター保存直後に “最新のユーザー情報” をクライアントへ返すため、極小の Server Action を用意します。
現行ポリシーどおり APIは作らず、Server Action のみで完結させます。
ts
1// src/app/_actions/auth-refresh.ts 2"use server"; 3 4import { lookupSessionFromCookie } from "@/lib/auth/session"; 5import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 6import type { AuthUserSnapshot } from "@/lib/auth/types"; 7 8export type RefreshResult = 9 | { ok: true; user: AuthUserSnapshot } 10 | { ok: false; message: string }; 11 12export async function refreshAuthSnapshotAction(): Promise<RefreshResult> { 13 const session = await lookupSessionFromCookie(); 14 if (!session.ok) return { ok: false, message: "認証が必要です" }; 15 16 const user = await getUserSnapshot(session.userId); 17 if (!user) return { ok: false, message: "ユーザー情報の取得に失敗しました" }; 18 19 return { ok: true, user }; 20}
上記は、現在のセッションの userId を基に getUserSnapshot() を呼び、Context 用の軽量データ(AuthUserSnapshot)を返すだけの Server Action です。
UI はこれを呼んで setUser() に渡すことで、ヘッダのアバターや氏名を即時に置き換えられます。

クライアント:プロフィール画面の送信フロー

本節では src/app/(protected)/profile/client.tsxAuthContext(useAuth())を唯一の情報源 に切り替え、
アップロード完了後は Server Action で最新スナップショットを再取得 → setUser() で即時反映 する形に統一します。
主な変更点は次のとおりです。
変更点内容ねらい
モック依存の排除mockUser の import を削除実データ(Context)に一本化
初期値の出所useAuth().user から name / email / roleCode / avatarUrl を組み立て表示と更新の一貫性
送信処理updateProfileAction()FormData で送信APIを使わずServer Actionで一気通貫
反映処理refreshAuthSnapshotAction() で最新の AuthUserSnapshot を取得 → setUser()ヘッダのアバター等を即時反映
キャッシュ回避avatarUrl?ts=<now> を一時付与ブラウザのキャッシュ無効化
UI配線ProfileFormonSubmit実処理 を渡すこれまでのプレースホルダーを置換
補足:ProfileForm 側は、onSubmit(values) を呼ぶだけでOK(サーバ通信は ProfileClient に集約)です。
tsx
1eRouter } from "next/navigation"; 2import { toast } from "sonner"; 3 4import ProfileForm from "@/components/profile/profile-form"; 5//import { mockUser } from "@/lib/sidebar/mock-user"; ←削除 6import type { ProfileUpdateValues } from "@/lib/users/schema"; 7import { useAuth } from "@/lib/auth/context"; 8// 3章で作成済みの Server Action 9import { updateProfileAction } from "@/app/_actions/profile-avatar"; 10// 本章で追加した“最新スナップショット取得”の Server Action 11import { refreshAuthSnapshotAction } from "@/app/_actions/auth-refresh"; 12 13export default function ProfileClient() { 14 const router = useRouter(); 15 const [pending, startTransition] = useTransition(); 16 17 const { user, ready, setUser } = useAuth(); 18 19 // AuthContext が未初期化の瞬間は描画を保留 20 if (!ready) return null; 21 22 // middleware / page.tsx 側で未ログインはリダイレクト済み想定 23 if (!user) return null; 24 25 const initial = { 26 name: user.name, 27 email: user.email, 28 roleCode: user.roleCode, 29 currentAvatarUrl: user.avatarUrl ?? undefined, 30 } as const; 31 32 const handleSubmit = (values: ProfileUpdateValues) => { 33 if (pending) return; 34 35 startTransition(async () => { 36 try { 37 // 1) Server Action へ送信 38 const fd = new FormData(); 39 fd.set("name", values.name); 40 if (values.avatarFile) fd.set("avatarFile", values.avatarFile); 41 42 const res = await updateProfileAction(fd); 43 if (!res.ok) { 44 toast.error(res.message ?? "プロフィールの更新に失敗しました"); 45 return; 46 } 47 48 // 2) 最新スナップショットを取得 → Contextへ即時反映 49 const snap = await refreshAuthSnapshotAction(); 50 if (snap.ok) { 51 // ブラウザキャッシュを避けるため一時的に ts を付与 52 const ts = Date.now(); 53 const updated = { 54 ...snap.user, 55 avatarUrl: 56 snap.user.avatarUrl != null 57 ? `${snap.user.avatarUrl}?ts=${ts}` 58 : null, 59 }; 60 setUser(updated); 61 } 62 63 toast.success("プロフィールを更新しました", { 64 description: values.avatarFile ? "画像も更新しました" : undefined, 65 duration: 3000, 66 }); 67 } catch (e) { 68 console.error(e); 69 toast.error( 70 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 71 ); 72 } 73 }); 74 }; 75 76 return ( 77 <ProfileForm 78 initial={initial} 79 onSubmit={handleSubmit} 80 onCancel={() => history.back()} 81 onNavigateEmail={() => router.push("/profile/email")} 82 onNavigatePassword={() => router.push("/profile/password")} 83 /> 84 ); 85}

コード解説

  • 初期値の一本化
    initialuseAuth().user からのみ組み立てます。これで 表示(フォーム)とヘッダのユーザ情報が常に同一ソース になります。
  • 送信(updateProfileAction
    FormDatanameavatarFile(任意)を送り、サーバ側で ファイル保存 → User.avatar 更新 を完了させます(APIは使いません)。
  • 反映(refreshAuthSnapshotAction
    DBが更新されたら直ちに 最新スナップショットを取得して setUser()。サイドバーのアバターや NavUser の表示が即座に切り替わります。
    なお、ブラウザ画像キャッシュを外すため ?ts= を一時付与しています(FCP重視。後段で ETag 運用に発展可)。
  • ユーザ体験
    成功時は「氏名のみ」「氏名+画像」のいずれにも対応したトーストを表示。エラー時は適切なメッセージを提示します。

profile-form.tsxの若干の変更

アバター画像について、next/image の最適化(Image Optimization API)が入ると、/_next/image?... 経由で再リクエストが走り、その際に Cookie が外れる or 認証が二重に必要になります。アバター画像自体はサイズが小さいものなので、unoptimizedオプションを指定して、Next.js 独自の Image Optimization を避け、直接 Route Handler のレスポンスを利用します。
tsx
1// src/components/profile/profile-form.tsx(一部抜粋) 2 3// ──省略 4 5 <div className="size-16 min-w-16 overflow-hidden rounded-full border"> 6 {previewUrl || currentAvatarUrl ? ( 7 <Image 8 src={previewUrl ?? currentAvatarUrl!} 9 alt="アバターのプレビュー" 10 width={64} 11 height={64} 12 className="h-full w-full object-cover" 13 unoptimized // ★追加 14 /> 15 ) : ( 16 <div className="text-muted-foreground flex h-full w-full items-center justify-center text-xs"> 17 No Image 18 </div> 19 )} 20 </div> 21
  • 認証が前提 の画像については、unoptimized を標準設定にします。

ヘッダーのアバターは自動で切り替わる

<NavUser />useAuth()user?.avatarUrl を参照して描画しており、5章の setUser(updated) により 即時でヘッダーの画像が切り替わります
NavUser 側のコード変更は不要です(2章・4章の実装に追随して自動反映されます)。

失敗時の挙動とUXメモ

保存~即時反映の間で起きうる失敗を整理し、UIのふるまいを決めておきます。
失敗ポイント想定原因UI のふるまい(推奨)
送信前のクライアント検証拡張子・容量・必須ProfileFormFormMessage に表示(既実装)
Server Action の入力検証MIME不正・シグネチャ不一致・容量超過res.fieldErrors.avatarFileFormMessage に表示
ファイル保存権限・ディレクトリ未作成res.message をトースト。権限/パスは 2章・3章の手順で確認
DB更新トランザクション失敗トースト+コンソールログ
スナップショット再取得セッション切れ・ネットワーク成功トーストは出すが setUser はスキップ(再読込で復旧)
反映直後に画像が古いままブラウザの一時キャッシュ?ts= 付与で解消(本章の実装)
運用ワンポイント
本番では PM2 の実行ユーザー(例:node)に /var/www/private/avatars の書込権限があることを必ず確認してください。
Nginx からはそのディレクトリを配信させず、Next.js の Route Handler 経由のみ にします(4章参照)。

動作確認チェックリスト

  • 画像未選択で「氏名のみ」更新 → ヘッダの氏名が即時反映される
  • 画像を選択して更新 → ヘッダのアバターがその場で差し替わる
  • 許可されない拡張子/容量超過 → FormMessage にエラー表示
  • 別ユーザーでログインして /avatar/[他人のuserId] へ直アクセス → 403/404 になる
  • ログアウト状態で /avatar/[userId] へアクセス → 401(または middleware によるリダイレクト)
  • 本番サーバで PM2 実行ユーザーが /var/www/private/avatars に書ける
— 以上で、フォーム送信からサーバ保存・配信・アプリ全体の即時反映までの UI 実装が完了です。次章では、テストケースと運用のベストプラクティス(バックアップ・削除ポリシーなど)を整理します。

6. まとめと次回予告

ここまでで、アバター画像の 保存・配信・即時反映 までの一連の仕組みを整備しました。
ログイン済みユーザーのプロフィール編集画面からアップロードし、認証・認可を通して安全に表示されるまでを、Server Action と Route Handler だけで実現できた点が大きなポイントです。
今回の実装を整理すると、以下のようになります。
txt
1[ProfileForm] --(FormData)--> [updateProfileAction] --(保存・DB更新)--> 2 --(refreshAuthSnapshotAction)--> [AuthContext.setUser()] 3 | 4 v 5 <NavUser /> 即時反映

今回の実装で得られたこと

項目内容
PrismaスキーマUser.avatar カラムを追加
保存処理Server Action で UUID+拡張子 形式で保存
配信処理Route Handler 経由で認証必須・同一Departmentのみ表示
即時反映Server Action で最新スナップショットを取得し、AuthContext を更新
UI側setUser() 呼び出しによりヘッダのアバターが即座に切り替わる
今回のアプローチにより、直リンク不可・セキュアな配信 が可能になり、かつ ユーザー体験の即時性 も確保できました。

次回予告

次回はアバター以外のプロフィール関連、すなわち 氏名・メール・パスワードといった基本情報のDB連携 を扱います。
具体的には以下のような機能を中心に進めていく予定です。
  • アバター画像の削除
  • プロフィール編集フォームからの 氏名変更
  • メールアドレス変更フロー(認証付きでの更新)
  • パスワード変更フォーム とサーバ側バリデーション
  • 変更後の即時反映と AuthContext 更新
アバター画像で実現した「Server Actionによる保存」と「スナップショット再取得」の仕組みを横展開し、プロフィール全体の編集を安全に完結できるようにします。
これにより、管理画面フォーマットのユーザー管理機能がさらに実務的な形へと近づきます。

参考文献

本記事の執筆にあたり、Next.js の公式ドキュメントや関連資料を参照しました。
特に App Router 時代における Server Actions / Route Handlers の扱いと、セキュリティ設計に関する情報は実装方針を定める上で重要です。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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