レビュー待ち·難易度: 中級·更新: 2026-04-18

Next.js Route Handler でファイルアップロードを受信・保存する

Route Handler で multipart/form-data を受信し、アップロードされたファイルをサーバー側に保存する例。ファイルサイズ・拡張子バリデーションパターンも示す。

nextjsapifile-upload

対応バージョン

nextjs 15react 19

前提環境

Next.js Route Handler の基本を理解していること

概要

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 版、サーバー側の受信・保存ロジックはこのパターン。両方を組み合わせて使う

注意点

react-hook-form-file-upload はクライアント側フォーム UI(ファイル選択・プレビュー・バリデーション)。これはサーバー側受信処理(Route Handler + request.formData() + ファイル書き出し)。

関連サンプル