概要
Route Handler で request.formData() を使い multipart/form-data を受信してファイルをサーバー側に保存する。ファイルサイズ・拡張子のバリデーションと、クライアント側のシンプルな送信フォームも合わせて示す。react-hook-form-file-upload との違いはクライアント UI vs サーバー受信。
インストール
# 追加インストールは不要
実装
アップロード API(Route Handler)
// app/api/upload/route.ts
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { NextResponse } from "next/server";
const UPLOAD_DIR = join(process.cwd(), "public", "uploads");
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
export async function POST(request: Request) {
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "file field is required" }, { status: 400 });
}
// ファイルサイズバリデーション
if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json(
{ error: `File too large. Max ${MAX_SIZE_BYTES / 1024 / 1024}MB` },
{ status: 400 }
);
}
// MIME タイプバリデーション
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}` },
{ status: 400 }
);
}
// ファイル名のサニタイズ(パストラバーサル防止)
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const safeName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
// 保存先ディレクトリを作成
await mkdir(UPLOAD_DIR, { recursive: true });
// バッファに変換して書き出し
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(join(UPLOAD_DIR, safeName), buffer);
return NextResponse.json(
{ url: `/uploads/${safeName}`, name: safeName, size: file.size },
{ status: 201 }
);
}
クライアント側アップロードフォーム
// components/FileUploadForm.tsx
"use client";
import { useState } from "react";
type UploadResult = { url: string; name: string; size: number };
export function FileUploadForm() {
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploading(true);
setError(null);
setResult(null);
try {
const res = await fetch("/api/upload", { method: "POST", body: formData });
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "アップロードに失敗しました");
return;
}
setResult(data as UploadResult);
} catch {
setError("ネットワークエラーが発生しました");
} finally {
setUploading(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
画像ファイル(最大 5MB)
</label>
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-gray-500
file:mr-4 file:rounded file:border-0
file:bg-blue-50 file:px-4 file:py-2
file:text-sm file:font-medium file:text-blue-700
hover:file:bg-blue-100"
/>
</div>
<button
type="submit"
disabled={!file || uploading}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{uploading ? "アップロード中..." : "アップロード"}
</button>
{error && (
<p className="rounded bg-red-50 p-3 text-sm text-red-600">{error}</p>
)}
{result && (
<div className="rounded bg-green-50 p-3 text-sm text-green-700">
<p>✓ アップロード成功</p>
<p className="mt-1 text-xs text-green-600">
{result.name} ({Math.round(result.size / 1024)} KB)
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={result.url} alt="uploaded" className="mt-2 max-h-32 rounded" />
</div>
)}
</form>
);
}
// app/page.tsx
import { FileUploadForm } from "@/components/FileUploadForm";
export default function Page() {
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="mb-6 text-2xl font-bold">ファイルアップロード</h1>
<FileUploadForm />
</main>
);
}
ポイント
request.formData()でmultipart/form-dataを受信し、formData.get("file")でFileオブジェクトを取得するfile instanceof Fileでフィールドの存在チェックを行う(stringが来る場合もあるため)- ファイル名は
Date.now()+ ランダム文字列で生成し、ユーザー入力のファイル名をそのまま使わない(パストラバーサル防止) - MIME タイプはクライアントで指定できるため、サーバー側でも
file.typeを検証する。実際のプロダクションではfile-typeパッケージでバイナリを検査するとより安全 react-hook-form-file-uploadとの使い分け: クライアントのバリデーション UI は RHF 版、サーバー側の受信・保存ロジックはこのパターン。両方を組み合わせて使う