![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #4Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
初回公開日
最終更新日
0. はじめに
これまで「管理画面フォーマット開発編」シリーズでは、ログイン機能とコンテキストを利用したユーザ情報の表示までを整えました。
今回のテーマは、ユーザプロフィールに欠かせない アバター画像のアップロードと表示 です。単純に「画像を保存して表示する」だけであれば難しくありませんが、本プロジェクトでは以下の要件を満たしたいと考えています。
- 画像は外部から直接アクセスできないよう保護すること
- 必ず認証・認可を通じて配信されること
- 将来的な拡張(CDNや外部ストレージ利用)にも対応できる設計であること
特に「画像を静的ファイルとして公開する」のではなく、Server Action を基盤にした仕組み でアップロードから配信までを扱う点が重要です。これにより、セッション情報やRBACロジックを統一的に適用でき、APIを最小化してセキュリティリスクを減らすことができます。
本記事では、Prismaのスキーマ拡張による
User
テーブルへの avatar
カラム追加から、サーバ上への保存処理、Shadcn/ui を利用したフォーム実装まで、アバター機能を安全に構築する流れを整理します。シリーズを通じて積み上げてきた 認証・認可の基盤 と一体化させることで、現場でも安心して利用できるユーザプロフィール管理を完成させていきます。技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
Ubuntu | 24.04 LTS | WebサーバOS |
Nginx | 1.28.x | Webサーバ |
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.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 Action | APIを増やさず認可チェックを一元管理 |
表示 | /avatar/[userId] ルート | 配信専用の仕組みに限定し安全に返却 |
2. Prismaスキーマへの拡張
アバター機能を扱うために、まずは
User
テーブルへ新しいカラムを追加します。ここでは、スキーマ修正と既存コードへの影響を整理し、必要な修正箇所を実際に示していきます。User.avatar を追加する理由
アバター画像は各ユーザが 1 つだけ保持する想定です。そのため別テーブルに切り出さず、最小構成として
このカラムには 保存先ディレクトリに置かれたファイル名(UUID+拡張子) を格納し、実際の配信時に組み合わせて利用します。
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.prisma
の User
モデルへ 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.ts | avatarUrl を DB の avatar から組み立てる処理に変更 |
src/lib/auth/types.ts | AuthUserSnapshot の 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アクションで完結 させます。フォームからの送信とクライアント側バリデーション
プロフィール編集フォームから、
name
と avatarFile
(任意)を送信します。クライアントでは既に profileUpdateSchema
により 拡張子・容量・推奨ピクセル をチェック済みですが、改ざん防止のためサーバ側でも必ず再検証 します。txt
1[ProfileForm]
2 ├─ zodで拡張子/容量チェック(UI)
3 └─ formDataで送信(name, avatarFile?)
4 ↓
5[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 |
許可MIME | image/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更新)
保存先は
DB には ファイル名のみ (例:
/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は
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」を比較して整理します。
App Router 時代の Next.js では、サーバ処理をどこに実装するか を理解しておくことが重要です。ここでは「Server Actions」「Route Handlers」「API Routes」を比較して整理します。
項目 | Server Actions | Route Handlers | API Routes (旧) |
---|---|---|---|
配置場所 | app/.../page.tsx 内や app/_actions/*.ts | app/.../route.ts | pages/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 / Response | Node/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 Action | updateProfileAction (3章実装済み) | 画像の検証・保存、User.avatar の更新 |
Server Action(新規) | refreshAuthSnapshotAction (本章で追加) | 最新の AuthUserSnapshot を取得して返却 |
Route Handler | /avatar/[userId] (4章実装済み) | 認証・認可を通じて画像バイトを返却 |
Context | AuthContext.setUser() | アプリ全体にユーザー情報を即時配信 |
Client | (protected)/profile/client.tsx | フォーム送信・トースト・スナップショット再取得・setUser 呼び出し |
補足(キャッシュ更新)
ただし、同一タブで連続更新の即時反映をより確実にするため、クライアント側で取得直後のみ
/avatar/[userId]
は Cache-Control: private, no-store
を返すため、通常は再読み込みで十分です。ただし、同一タブで連続更新の即時反映をより確実にするため、クライアント側で取得直後のみ
?ts=...
のような一時的なクエリを付与して ワンショットでキャッシュバイパス するのが実務的です(本章の実装に含めます)。スナップショット再取得の Server Action を追加
アバター保存直後に “最新のユーザー情報” をクライアントへ返すため、極小の Server Action を用意します。
現行ポリシーどおり APIは作らず、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 を基に
UI はこれを呼んで
getUserSnapshot()
を呼び、Context 用の軽量データ(AuthUserSnapshot
)を返すだけの Server Action です。UI はこれを呼んで
setUser()
に渡すことで、ヘッダのアバターや氏名を即時に置き換えられます。クライアント:プロフィール画面の送信フロー
本節では
アップロード完了後は Server Action で最新スナップショットを再取得 →
src/app/(protected)/profile/client.tsx
を AuthContext(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配線 | ProfileForm の onSubmit に 実処理 を渡す | これまでのプレースホルダーを置換 |
補足:
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}
コード解説
-
初期値の一本化
initial
はuseAuth().user
からのみ組み立てます。これで 表示(フォーム)とヘッダのユーザ情報が常に同一ソース になります。 -
送信(
updateProfileAction
)
FormData
でname
とavatarFile
(任意)を送り、サーバ側で ファイル保存 →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 のふるまい(推奨) |
---|---|---|
送信前のクライアント検証 | 拡張子・容量・必須 | ProfileForm の FormMessage に表示(既実装) |
Server Action の入力検証 | MIME不正・シグネチャ不一致・容量超過 | res.fieldErrors.avatarFile を FormMessage に表示 |
ファイル保存 | 権限・ディレクトリ未作成 | res.message をトースト。権限/パスは 2章・3章の手順で確認 |
DB更新 | トランザクション失敗 | トースト+コンソールログ |
スナップショット再取得 | セッション切れ・ネットワーク | 成功トーストは出すが setUser はスキップ(再読込で復旧) |
反映直後に画像が古いまま | ブラウザの一時キャッシュ | ?ts= 付与で解消(本章の実装) |
運用ワンポイント
本番では PM2 の実行ユーザー(例:
Nginx からはそのディレクトリを配信させず、Next.js の Route Handler 経由のみ にします(4章参照)。
本番では 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 だけで実現できた点が大きなポイントです。
ログイン済みユーザーのプロフィール編集画面からアップロードし、認証・認可を通して安全に表示されるまでを、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 の扱いと、セキュリティ設計に関する情報は実装方針を定める上で重要です。
特に App Router 時代における Server Actions / Route Handlers の扱いと、セキュリティ設計に関する情報は実装方針を定める上で重要です。
-
Next.js Documentation – Route Handlers and Middleware
App Router における Route Handler の基本と、Middleware との使い分けについて解説。 -
Next.js Documentation – Server Actions
フォーム送信やデータ更新を API を介さず実現する方法をまとめた公式ガイド。 -
Prisma Documentation – Relations and Data Modeling
Prisma スキーマの設計と、User モデルへのフィールド追加に関するリファレンス。 -
MDN Web Docs – MIME types (IANA media types)
アバター画像の MIME 判定や拡張子管理の基礎知識。 -
Node.js Documentation – fs/promises
ファイルの保存・削除処理に利用する fs/promises API のリファレンス。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
2025/9/12公開
![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
2025/9/10公開
![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero-thumbnail.jpg&w=1200&q=75)
JWTとロールでAPIを守る ─ RBAC導入とGuard関数実装
APIを安全にする鍵は「ロールベースの認可」。JWTのpayloadに含めたロール情報を活用し、Admin専用APIの実装を通じてRBACの基本を実践
2025/8/5公開

Prisma × PostgreSQLで始めるユーザー・ロール管理
スキーマ設計とDB連携の基礎構築を通じて、認可の土台となるユーザー・ロール情報の管理を実践
2025/8/3公開
