T-CREATOR

Next.js の Route Handlers で multipart/form-data が受け取れない問題の切り分け術

Next.js の Route Handlers で multipart/form-data が受け取れない問題の切り分け術

Next.js の App Router で Route Handlers を使ってファイルアップロード機能を実装する際、multipart/form-data が正しく受け取れないという問題に直面したことはありませんか?

この記事では、Next.js の Route Handlers における multipart/form-data の処理で発生しがちな問題とその切り分け方法について、実践的な視点から解説していきます。エラーの原因を特定し、確実に動作する実装を目指しましょう。

背景

Next.js 13 以降の App Router では、API ルートの実装方法が従来の Pages Router から大きく変わりました。Route Handlers という新しい仕組みが導入され、より柔軟な API エンドポイントの実装が可能になっています。

しかし、この新しいアーキテクチャには独自の特性があり、特にファイルアップロードのような multipart/form-data を扱う処理では、従来の方法がそのまま使えないケースが多いのです。

Route Handlers の基本構造

Route Handlers は、app/api/route.ts のような形で定義され、HTTP メソッドに応じた処理を実装できます。以下の図は Route Handlers の基本的な処理フローを示しています。

mermaidflowchart TB
  client["クライアント<br/>(ブラウザ)"] -->|"POST<br/>multipart/form-data"| handler["Route Handler<br/>(app/api/upload/route.ts)"]
  handler -->|"request.formData()"| parse["FormData<br/>パース処理"]
  parse -->|成功| success["ファイル取得<br/>処理実行"]
  parse -->|失敗| error["エラー発生<br/>(データ取得不可)"]
  success --> response["Response<br/>(JSON)"]
  error --> errResponse["Error Response<br/>(エラーメッセージ)"]
  response --> client
  errResponse --> client

このフローからわかるように、クライアントから送信された multipart/form-data は Route Handler で受け取り、FormData としてパースする必要があります。しかし、この過程で様々な問題が発生する可能性があるのです。

Pages Router との違い

従来の Pages Router では、next-connectmulter といったミドルウェアを活用することが一般的でした。しかし、Route Handlers では Web 標準の Request/Response API を使用するため、アプローチが異なります。

#項目Pages RouterRoute Handlers
1実装場所pages/api/*.tsapp/api/*/route.ts
2リクエスト型NextApiRequestRequest (Web API)
3レスポンス型NextApiResponseResponse (Web API)
4FormData 取得ミドルウェア必須標準メソッド利用可
5bodyParser 設定必要不要 (自動処理)

この違いを理解せずに実装すると、データが正しく受け取れない問題が発生します。

課題

Next.js の Route Handlers で multipart/form-data を扱う際には、いくつかの典型的な問題が発生しやすくなっています。これらの課題を正しく理解し、切り分けることが重要です。

よくある問題パターン

multipart/form-data が受け取れない問題は、主に以下の 4 つのパターンに分類できます。それぞれ原因と症状が異なるため、適切な切り分けが必要です。

以下の図は、問題発生時の切り分けフローを示しています。

mermaidflowchart TD
  start["multipart/form-data<br/>受け取れない"] --> check1{"Content-Type<br/>正しい?"}
  check1 -->|いいえ| issue1["課題1:<br/>Content-Type 未設定"]
  check1 -->|はい| check2{"FormData API<br/>使用?"}
  check2 -->|いいえ| issue2["課題2:<br/>パース方法誤り"]
  check2 -->|はい| check3{"ファイルサイズ<br/>確認?"}
  check3 -->|超過| issue3["課題3:<br/>サイズ制限超過"]
  check3 -->|適正| check4{"エラーログ<br/>確認?"}
  check4 -->|型エラー| issue4["課題4:<br/>型定義の問題"]
  check4 -->|その他| issue5["課題5:<br/>設定・環境問題"]

課題 1: Content-Type ヘッダーの設定ミス

クライアント側で Content-Type を明示的に設定してしまうことで、multipart/form-data の boundary パラメータが欠落するケースがあります。

エラーコード

plaintextTypeError: Failed to parse multipart/form-data

エラーメッセージ

plaintextError: Invalid multipart/form-data: missing boundary parameter
Request failed with status 400: Bad Request

発生条件

  • fetch API で headers: { 'Content-Type': 'multipart​/​form-data' } を明示的に設定
  • boundary パラメータが自動付与されない
  • サーバー側でデータの区切りを識別できない

課題 2: FormData のパース方法の誤り

Route Handlers では Web 標準の FormData API を使用する必要がありますが、従来の方法で body を直接読み取ろうとするとエラーになります。

エラーコード

plaintextError: Cannot read body of request

エラーメッセージ

plaintextTypeError: request.body is not a function
SyntaxError: Unexpected token in JSON at position 0
Error: Body is already used

発生条件

  • request.json()request.text() で multipart データを読み取ろうとする
  • body を複数回読み取ろうとする
  • ストリームが既に消費されている

課題 3: ファイルサイズ制限

Next.js では、デフォルトで Request Body のサイズ制限があります。大きなファイルをアップロードすると、サーバー側で受け取る前に拒否されることがあります。

エラーコード

plaintextError 413: Payload Too Large

エラーメッセージ

plaintextPayloadTooLargeError: request entity too large
Error: Body exceeded 4mb limit

発生条件

  • デフォルトのサイズ制限 (4MB) を超えるファイルのアップロード
  • next.config.js でサイズ制限の設定がない
  • サーバーレス環境での制限超過

課題 4: TypeScript 型定義の問題

FormData から取得したファイルの型が File | string | null となるため、適切な型チェックをしないと実行時エラーが発生します。

エラーコード

plaintextTypeError: file.arrayBuffer is not a function

エラーメッセージ

plaintextTypeError: Cannot read property 'arrayBuffer' of null
TypeError: file.arrayBuffer is not a function
Error: Expected File, got string

発生条件

  • FormData.get() の返り値を File 型として直接使用
  • null チェックが不足
  • string 型のフィールドを File として処理

これらの課題を正しく切り分けることで、効率的に問題を解決できます。

解決策

それでは、各課題に対する具体的な解決策を見ていきましょう。問題の切り分けから修正までの手順を段階的に解説します。

解決策 1: Content-Type の正しい設定

クライアント側で FormData を送信する際は、Content-Type ヘッダーを明示的に設定しないことが重要です。ブラウザが自動的に適切な boundary パラメータを付与してくれます。

クライアント側の実装(誤った例)

typescript// ❌ 誤った実装: Content-Type を明示的に設定
async function uploadFile(file: File) {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/api/upload', {
    method: 'POST',
    headers: {
      'Content-Type': 'multipart/form-data', // これが問題
    },
    body: formData,
  });
}

この実装では、Content-Type に boundary パラメータが含まれないため、サーバー側でデータの区切りを識別できません。

クライアント側の実装(正しい例)

typescript// ✓ 正しい実装: Content-Type を指定しない
async function uploadFile(file: File) {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/api/upload', {
    method: 'POST',
    // headers は指定しない
    body: formData,
  });

  return response;
}

ブラウザが自動的に Content-Type: multipart​/​form-data; boundary=----WebKitFormBoundary... のような適切なヘッダーを付与してくれます。

切り分け方法: ネットワークタブで確認

問題が Content-Type に起因するかどうかは、ブラウザの開発者ツールで確認できます。

  1. ブラウザの開発者ツールを開く (F12 または Cmd+Option+I)
  2. Network (ネットワーク) タブを選択
  3. ファイルアップロードを実行
  4. リクエストを選択し、Headers セクションを確認

boundary パラメータが含まれているか確認しましょう。

plaintext# 正しい例
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXXXXXXXXXXXX

# 誤った例 (boundary がない)
Content-Type: multipart/form-data

解決策 2: FormData API の正しい使用

Route Handlers では、request.formData() メソッドを使用して multipart/form-data をパースします。この方法が最もシンプルで確実です。

サーバー側の実装(基本形)

typescript// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    // FormData としてパース
    const formData = await request.formData();

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('FormData parse error:', error);
    return NextResponse.json(
      { error: 'Failed to parse form data' },
      { status: 400 }
    );
  }
}

request.formData() は Promise を返すため、await で待機する必要があります。

ファイルの取得と検証

typescript// FormData からファイルを取得
const file = formData.get('file');

// null チェック
if (!file) {
  return NextResponse.json(
    { error: 'File not found in form data' },
    { status: 400 }
  );
}

// 型チェック: File インスタンスかどうか
if (!(file instanceof File)) {
  return NextResponse.json(
    { error: 'Invalid file format' },
    { status: 400 }
  );
}

FormData.get() の返り値は FormDataEntryValue | null 型 (つまり File | string | null) となるため、必ず型チェックが必要です。

切り分け方法: ログ出力で確認

FormData が正しくパースできているか、段階的にログを出力して確認します。

typescriptexport async function POST(request: NextRequest) {
  console.log('1. Request received');

  try {
    const formData = await request.formData();
    console.log('2. FormData parsed successfully');

    // FormData の全エントリーを出力
    for (const [key, value] of formData.entries()) {
      console.log(
        `3. FormData entry - ${key}:`,
        value instanceof File
          ? `File(${value.name})`
          : value
      );
    }

    const file = formData.get('file');
    console.log('4. File retrieved:', file instanceof File);
  } catch (error) {
    console.error('Error at step:', error);
  }
}

このログ出力により、どの段階で問題が発生しているか特定できます。

解決策 3: ファイルサイズ制限の設定

大きなファイルをアップロードする場合は、Next.js の設定でサイズ制限を調整する必要があります。

next.config.js の設定

javascript// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // API Routes のボディサイズ制限を設定
  api: {
    bodyParser: {
      sizeLimit: '10mb', // 10MB まで許可
    },
  },
};

module.exports = nextConfig;

ただし、App Router の Route Handlers では、この設定が効かない場合があります。

Route Handlers でのサイズ制限設定

Route Handlers では、route segment config を使用してサイズ制限を設定します。

typescript// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';

// Route Segment Config でサイズ制限を設定
export const maxDuration = 60; // 最大実行時間 (秒)
export const dynamic = 'force-dynamic'; // 動的レンダリング強制

export async function POST(request: NextRequest) {
  // 実装
}

手動でサイズチェックを実装

より確実な方法として、ファイル取得後に手動でサイズをチェックすることをおすすめします。

typescriptconst file = formData.get('file');

if (!(file instanceof File)) {
  return NextResponse.json(
    { error: 'Invalid file format' },
    { status: 400 }
  );
}

// ファイルサイズチェック (10MB = 10 * 1024 * 1024 bytes)
const MAX_FILE_SIZE = 10 * 1024 * 1024;

if (file.size > MAX_FILE_SIZE) {
  return NextResponse.json(
    {
      error: `File size exceeds limit. Max: ${
        MAX_FILE_SIZE / 1024 / 1024
      }MB`,
    },
    { status: 413 }
  );
}

切り分け方法: サイズの段階的テスト

以下の手順でファイルサイズ制限を特定します。

  1. 小さいファイル (1KB) でテスト → 成功
  2. 中程度のファイル (1MB) でテスト → 成功 or 失敗
  3. 大きいファイル (5MB) でテスト → 失敗
  4. 制限値を特定し、設定を調整
#ファイルサイズ結果判定
1100 KB成功OK
21 MB成功OK
35 MB失敗制限は 1-5MB の間
43 MB失敗制限は 1-3MB の間
52 MB成功制限は 2-3MB (約 4MB と推測)

解決策 4: TypeScript 型安全な実装

型安全性を確保しつつ、ファイルを処理する実装パターンを紹介します。

型ガード関数の定義

typescript// lib/typeGuards.ts

/**
 * File インスタンスかどうかを判定する型ガード
 */
export function isFile(value: unknown): value is File {
  return value instanceof File;
}

/**
 * FormDataEntryValue が File かどうかを判定
 */
export function isFormDataFile(
  value: FormDataEntryValue | null
): value is File {
  return value !== null && value instanceof File;
}

型ガード関数を使用することで、TypeScript が型を正しく推論してくれます。

型安全なファイル取得関数

typescript// lib/fileUtils.ts
import { isFormDataFile } from './typeGuards';

/**
 * FormData から安全にファイルを取得
 * @throws Error ファイルが見つからない、または無効な場合
 */
export function getFileFromFormData(
  formData: FormData,
  fieldName: string
): File {
  const value = formData.get(fieldName);

  if (!isFormDataFile(value)) {
    throw new Error(
      `Invalid file: field "${fieldName}" is not a valid File object`
    );
  }

  return value; // この時点で File 型として確定
}

この関数を使用することで、エラーハンドリングと型チェックを一箇所にまとめられます。

Route Handler での使用例

typescript// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getFileFromFormData } from '@/lib/fileUtils';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();

    // 型安全にファイルを取得
    const file = getFileFromFormData(formData, 'file');

    // この時点で file は File 型として確定
    console.log('File name:', file.name);
    console.log('File size:', file.size);
    console.log('File type:', file.type);

    return NextResponse.json({
      success: true,
      fileName: file.name,
      fileSize: file.size,
    });
  } catch (error) {
    console.error('Upload error:', error);

    return NextResponse.json(
      {
        error:
          error instanceof Error
            ? error.message
            : 'Unknown error occurred',
      },
      { status: 400 }
    );
  }
}

ファイル内容の読み取り

typescript// File を ArrayBuffer として読み取る
const arrayBuffer = await file.arrayBuffer();

// Buffer に変換 (Node.js 環境)
const buffer = Buffer.from(arrayBuffer);

// または Uint8Array として扱う
const uint8Array = new Uint8Array(arrayBuffer);

ファイルの内容を処理する際は、arrayBuffer() メソッドを使用してバイナリデータを取得します。

切り分けチェックリスト

問題が発生した際に確認すべき項目を整理しました。上から順番にチェックしていくことで、効率的に原因を特定できます。

#チェック項目確認方法該当する課題
1Content-Type に boundary があるかブラウザの Network タブ課題 1
2request.formData() を使用しているかコード確認課題 2
3FormData が正しくパースされるかconsole.log で確認課題 2
4ファイルサイズは制限内かfile.size の値確認課題 3
5File インスタンスの型チェックをしているかinstanceof File 確認課題 4
6null チェックを実施しているかif (!file) の確認課題 4
7エラーログは出力されているかサーバーログ確認全般

具体例

ここまでの解決策を踏まえて、実際に動作する完全なファイルアップロード機能を実装してみましょう。クライアント側とサーバー側の両方を含む、実践的なコード例を紹介します。

実装の全体像

以下の図は、クライアントからサーバーまでの完全なファイルアップロードフローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Form as アップロード<br/>フォーム
  participant API as Route Handler<br/>(POST /api/upload)
  participant FS as ファイル<br/>システム

  User->>Form: ファイル選択
  Form->>Form: FormData 生成
  Form->>API: POST リクエスト<br/>(multipart/form-data)
  API->>API: request.formData()
  API->>API: ファイル検証<br/>(型・サイズ)
  alt 検証失敗
    API->>Form: Error Response<br/>(400/413)
    Form->>User: エラー表示
  else 検証成功
    API->>API: arrayBuffer() 取得
    API->>FS: ファイル保存
    FS->>API: 保存完了
    API->>Form: Success Response<br/>(200 + metadata)
    Form->>User: 成功メッセージ表示
  end

このシーケンス図から、各段階での処理と検証のポイントがわかります。

プロジェクト構成

plaintextproject-root/
├── app/
│   ├── api/
│   │   └── upload/
│   │       └── route.ts          # Route Handler
│   └── upload/
│       └── page.tsx               # アップロードページ
├── lib/
│   ├── typeGuards.ts              # 型ガード関数
│   ├── fileUtils.ts               # ファイル処理ユーティリティ
│   └── validators.ts              # バリデーション関数
└── types/
    └── upload.ts                  # 型定義

型定義ファイル

まず、共通で使用する型を定義します。

typescript// types/upload.ts

/**
 * アップロードレスポンス (成功時)
 */
export interface UploadSuccessResponse {
  success: true;
  fileName: string;
  fileSize: number;
  fileType: string;
  uploadedAt: string;
}

/**
 * アップロードレスポンス (失敗時)
 */
export interface UploadErrorResponse {
  success: false;
  error: string;
  errorCode?: string;
}

/**
 * アップロードレスポンスの統合型
 */
export type UploadResponse =
  | UploadSuccessResponse
  | UploadErrorResponse;

型定義を分離することで、クライアントとサーバーの両方で同じ型を使用できます。

型ガード関数

typescript// lib/typeGuards.ts

/**
 * File インスタンスかどうかを判定する型ガード
 */
export function isFile(value: unknown): value is File {
  return value instanceof File;
}

/**
 * FormDataEntryValue が File かどうかを判定
 */
export function isFormDataFile(
  value: FormDataEntryValue | null
): value is File {
  return value !== null && value instanceof File;
}

/**
 * UploadSuccessResponse かどうかを判定
 */
export function isUploadSuccess(
  response: unknown
): response is import('@/types/upload').UploadSuccessResponse {
  return (
    typeof response === 'object' &&
    response !== null &&
    'success' in response &&
    response.success === true
  );
}

バリデーション関数

typescript// lib/validators.ts

/**
 * ファイルサイズの制限 (10MB)
 */
const MAX_FILE_SIZE = 10 * 1024 * 1024;

/**
 * 許可するファイルタイプ
 */
const ALLOWED_FILE_TYPES = [
  'image/jpeg',
  'image/png',
  'image/gif',
  'image/webp',
  'application/pdf',
];

/**
 * ファイルサイズを検証
 */
export function validateFileSize(file: File): {
  valid: boolean;
  error?: string;
} {
  if (file.size > MAX_FILE_SIZE) {
    return {
      valid: false,
      error: `ファイルサイズが大きすぎます。最大 ${
        MAX_FILE_SIZE / 1024 / 1024
      }MB まで対応しています。`,
    };
  }
  return { valid: true };
}

/**
 * ファイルタイプを検証
 */
export function validateFileType(file: File): {
  valid: boolean;
  error?: string;
} {
  if (!ALLOWED_FILE_TYPES.includes(file.type)) {
    return {
      valid: false,
      error: `ファイル形式がサポートされていません。対応形式: ${ALLOWED_FILE_TYPES.join(
        ', '
      )}`,
    };
  }
  return { valid: true };
}

/**
 * ファイル全体を検証
 */
export function validateFile(file: File): {
  valid: boolean;
  error?: string;
} {
  const sizeValidation = validateFileSize(file);
  if (!sizeValidation.valid) {
    return sizeValidation;
  }

  const typeValidation = validateFileType(file);
  if (!typeValidation.valid) {
    return typeValidation;
  }

  return { valid: true };
}

バリデーション処理を関数として分離することで、テストやメンテナンスが容易になります。

ファイル処理ユーティリティ

typescript// lib/fileUtils.ts
import { isFormDataFile } from './typeGuards';
import { validateFile } from './validators';

/**
 * FormData から安全にファイルを取得
 * @throws Error ファイルが見つからない、または無効な場合
 */
export function getFileFromFormData(
  formData: FormData,
  fieldName: string
): File {
  const value = formData.get(fieldName);

  if (!isFormDataFile(value)) {
    throw new Error(
      `フィールド "${fieldName}" が見つからないか、有効なファイルではありません。`
    );
  }

  return value;
}

/**
 * ファイルを検証して取得
 * @throws Error バリデーションエラーが発生した場合
 */
export function getValidatedFile(
  formData: FormData,
  fieldName: string
): File {
  const file = getFileFromFormData(formData, fieldName);

  const validation = validateFile(file);
  if (!validation.valid) {
    throw new Error(validation.error);
  }

  return file;
}

Route Handler の実装

次に、サーバー側の Route Handler を実装します。

typescript// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import path from 'path';
import { getValidatedFile } from '@/lib/fileUtils';
import type { UploadResponse } from '@/types/upload';

// Route Segment Config
export const maxDuration = 60; // 最大実行時間 (秒)
export const dynamic = 'force-dynamic'; // 動的レンダリング強制

/**
 * ファイルアップロード API
 */
export async function POST(
  request: NextRequest
): Promise<NextResponse<UploadResponse>> {
  console.log('[Upload API] Request received');

  try {
    // 1. FormData をパース
    const formData = await request.formData();
    console.log(
      '[Upload API] FormData parsed successfully'
    );

    // 2. ファイルを検証付きで取得
    const file = getValidatedFile(formData, 'file');
    console.log(
      `[Upload API] File validated: ${file.name} (${file.size} bytes)`
    );

    // 3. ファイル内容を取得
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // 4. 保存先のパスを生成 (public/uploads ディレクトリ)
    const uploadDir = path.join(
      process.cwd(),
      'public',
      'uploads'
    );
    const fileName = `${Date.now()}-${file.name}`;
    const filePath = path.join(uploadDir, fileName);

    // 5. ファイルを保存
    await writeFile(filePath, buffer);
    console.log(`[Upload API] File saved: ${filePath}`);

    // 6. 成功レスポンス
    return NextResponse.json({
      success: true,
      fileName: file.name,
      fileSize: file.size,
      fileType: file.type,
      uploadedAt: new Date().toISOString(),
    });
  } catch (error) {
    console.error('[Upload API] Error:', error);

    // エラーメッセージを取得
    const errorMessage =
      error instanceof Error
        ? error.message
        : 'アップロード処理中に不明なエラーが発生しました。';

    // エラーコードを判定
    let statusCode = 400;
    let errorCode = 'UPLOAD_ERROR';

    if (errorMessage.includes('サイズが大きすぎます')) {
      statusCode = 413;
      errorCode = 'FILE_TOO_LARGE';
    } else if (
      errorMessage.includes('サポートされていません')
    ) {
      statusCode = 415;
      errorCode = 'UNSUPPORTED_FILE_TYPE';
    }

    return NextResponse.json(
      {
        success: false,
        error: errorMessage,
        errorCode,
      },
      { status: statusCode }
    );
  }
}

このコードでは、エラーハンドリングとログ出力を適切に行い、問題の切り分けがしやすい構造になっています。

クライアント側コンポーネント (基本構造)

typescript// app/upload/page.tsx
'use client';

import { useState, FormEvent } from 'react';
import type { UploadResponse } from '@/types/upload';
import { isUploadSuccess } from '@/lib/typeGuards';

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [message, setMessage] = useState<string>('');
  const [error, setError] = useState<string>('');

  return (
    <div>
      {/* UI コンポーネントは次のセクションで実装 */}
    </div>
  );
}

クライアント側: ファイル選択ハンドラー

typescript/**
 * ファイル選択時のハンドラー
 */
const handleFileChange = (
  e: React.ChangeEvent<HTMLInputElement>
) => {
  const selectedFile = e.target.files?.[0];

  if (selectedFile) {
    setFile(selectedFile);
    setError('');
    setMessage('');
    console.log('File selected:', selectedFile.name);
  }
};

ファイルが選択されたタイミングで状態を更新し、エラーメッセージをクリアします。

クライアント側: アップロード処理

typescript/**
 * フォーム送信時のハンドラー
 */
const handleSubmit = async (
  e: FormEvent<HTMLFormElement>
) => {
  e.preventDefault();

  if (!file) {
    setError('ファイルを選択してください。');
    return;
  }

  setUploading(true);
  setError('');
  setMessage('');

  try {
    // FormData を作成
    const formData = new FormData();
    formData.append('file', file);

    console.log('Uploading file:', file.name);

    // API にリクエスト (Content-Type は指定しない)
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
      // headers は指定しない (重要!)
    });

    // レスポンスをパース
    const data: UploadResponse = await response.json();

    // 成功判定
    if (isUploadSuccess(data)) {
      setMessage(
        `アップロード成功!\nファイル名: ${
          data.fileName
        }\nサイズ: ${(data.fileSize / 1024).toFixed(2)} KB`
      );
      setFile(null);
      console.log('Upload successful:', data);
    } else {
      setError(
        data.error || 'アップロードに失敗しました。'
      );
      console.error('Upload failed:', data);
    }
  } catch (error) {
    console.error('Upload error:', error);
    setError(
      error instanceof Error
        ? error.message
        : 'ネットワークエラーが発生しました。'
    );
  } finally {
    setUploading(false);
  }
};

この実装では、fetch API を使用し、Content-Type を指定しないことで正しい boundary パラメータが付与されます。

クライアント側: UI コンポーネント

typescriptreturn (
  <div className='max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md'>
    <h1 className='text-2xl font-bold mb-6'>
      ファイルアップロード
    </h1>

    <form onSubmit={handleSubmit} className='space-y-4'>
      {/* ファイル選択 */}
      <div>
        <label
          htmlFor='file-input'
          className='block text-sm font-medium text-gray-700 mb-2'
        >
          ファイルを選択
        </label>
        <input
          id='file-input'
          type='file'
          onChange={handleFileChange}
          disabled={uploading}
          className='block w-full text-sm text-gray-500
            file:mr-4 file:py-2 file:px-4
            file:rounded-md file:border-0
            file:text-sm file:font-semibold
            file:bg-blue-50 file:text-blue-700
            hover:file:bg-blue-100
            disabled:opacity-50 disabled:cursor-not-allowed'
        />
      </div>

      {/* 選択中のファイル情報 */}
      {file && (
        <div className='text-sm text-gray-600'>
          選択中: {file.name} (
          {(file.size / 1024).toFixed(2)} KB)
        </div>
      )}

      {/* アップロードボタン */}
      <button
        type='submit'
        disabled={!file || uploading}
        className='w-full py-2 px-4 bg-blue-600 text-white rounded-md
          hover:bg-blue-700 disabled:bg-gray-400
          disabled:cursor-not-allowed transition-colors'
      >
        {uploading ? 'アップロード中...' : 'アップロード'}
      </button>
    </form>

    {/* 成功メッセージ */}
    {message && (
      <div className='mt-4 p-4 bg-green-50 border border-green-200 rounded-md'>
        <p className='text-sm text-green-800 whitespace-pre-line'>
          {message}
        </p>
      </div>
    )}

    {/* エラーメッセージ */}
    {error && (
      <div className='mt-4 p-4 bg-red-50 border border-red-200 rounded-md'>
        <p className='text-sm text-red-800'>{error}</p>
      </div>
    )}
  </div>
);

uploads ディレクトリの作成

ファイル保存先のディレクトリを事前に作成しておきます。

bashmkdir -p public/uploads

このコマンドで public​/​uploads ディレクトリが作成されます。

.gitignore の設定

アップロードされたファイルを Git 管理から除外します。

plaintext# public/uploads/.gitignore
# アップロードされたファイルを除外
*
!.gitignore

動作確認の手順

実装が完了したら、以下の手順で動作確認を行います。

#確認項目手順期待結果
1正常系1MB の画像ファイルをアップロード成功メッセージ表示
2サイズ超過11MB のファイルをアップロードエラーコード 413 表示
3非対応形式.txt ファイルをアップロードエラーコード 415 表示
4ファイル未選択ファイル選択せずに送信クライアント側でエラー
5Content-Type 確認Network タブで確認boundary パラメータあり
6ログ確認ターミナルでログ確認各段階でログ出力

トラブルシューティング: よくあるエラーと対処法

実装時に発生しやすいエラーとその対処法を整理しました。

エラー 1: TypeError: Failed to parse multipart/form-data

plaintextTypeError: Failed to parse multipart/form-data

原因: Content-Type に boundary パラメータがない

対処法: クライアント側で headers の Content-Type を削除する

typescript// ❌ 誤り
fetch('/api/upload', {
  headers: { 'Content-Type': 'multipart/form-data' },
  body: formData,
});

// ✓ 正しい
fetch('/api/upload', {
  body: formData,
});

エラー 2: Error: Body is already used

plaintextError: Body is already used

原因: request.formData() を複数回呼び出している

対処法: formData を変数に保存して再利用する

typescript// ❌ 誤り
await request.formData();
await request.formData(); // エラー

// ✓ 正しい
const formData = await request.formData();
// formData を再利用

エラー 3: TypeError: file.arrayBuffer is not a function

plaintextTypeError: file.arrayBuffer is not a function

原因: file が File インスタンスではない (string または null)

対処法: instanceof でチェックする

typescriptconst file = formData.get('file');

if (!(file instanceof File)) {
  throw new Error('Invalid file');
}

// この時点で file は File 型
const buffer = await file.arrayBuffer();

これらのエラーパターンを理解しておくことで、問題発生時に素早く対応できます。

まとめ

Next.js の Route Handlers で multipart/form-data を扱う際の問題切り分け術について解説してきました。最後に重要なポイントをおさらいしましょう。

重要ポイントの振り返り

multipart/form-data を確実に受け取るためには、以下の 4 つのポイントを押さえることが重要です。

1. Content-Type は指定しない

クライアント側で fetch を使用する際、Content-Type ヘッダーは指定せず、ブラウザに自動付与させることで、正しい boundary パラメータが含まれます。

2. request.formData() を使用する

Route Handlers では、Web 標準の FormData API である request.formData() メソッドを使用してデータをパースします。従来の bodyParser ベースの方法は使用できません。

3. ファイルサイズ制限に注意

デフォルトでは 4MB 程度の制限があるため、大きなファイルを扱う場合は手動でサイズチェックを実装し、適切なエラーメッセージを返すことが重要です。

4. 型安全性を確保する

FormData.get() の返り値は File | string | null となるため、instanceof チェックと null チェックを必ず行い、型安全な実装を心がけましょう。

問題切り分けのフロー

問題が発生した際は、以下の順序で確認することで効率的に原因を特定できます。

mermaidflowchart TD
  start["multipart/form-data<br/>受け取れない問題発生"] --> step1["1. Network タブで<br/>Content-Type 確認"]
  step1 --> check1{"boundary<br/>パラメータ<br/>あり?"}
  check1 -->|なし| fix1["fetch の headers から<br/>Content-Type を削除"]
  check1 -->|あり| step2["2. サーバーログで<br/>FormData パース確認"]
  step2 --> check2{"FormData<br/>パース<br/>成功?"}
  check2 -->|失敗| fix2["request.formData()<br/>を使用しているか確認"]
  check2 -->|成功| step3["3. ファイルサイズ<br/>確認"]
  step3 --> check3{"サイズ<br/>制限内?"}
  check3 -->|超過| fix3["サイズチェック実装<br/>または制限緩和"]
  check3 -->|OK| step4["4. 型チェック<br/>確認"]
  step4 --> check4{"instanceof<br/>File<br/>チェック?"}
  check4 -->|なし| fix4["型ガード関数で<br/>チェック追加"]
  check4 -->|あり| step5["5. その他の<br/>環境・設定確認"]
  fix1 --> resolved["問題解決"]
  fix2 --> resolved
  fix3 --> resolved
  fix4 --> resolved
  step5 --> resolved

実装時のベストプラクティス

実際の開発では、以下のベストプラクティスを意識することで、保守性の高いコードが書けます。

#ベストプラクティス理由
1型ガード関数を作成する型安全性を確保し、エラーを未然に防ぐ
2バリデーションを分離するテストしやすく、再利用可能になる
3エラーコードを明確にする問題の切り分けが容易になる
4ログを適切に出力するデバッグ時に原因を特定しやすい
5エラーメッセージを丁寧にユーザーが対処方法を理解できる
6サイズ制限を明示する期待値を明確にし、混乱を防ぐ

今後の展開

この記事で紹介した基本的な実装をベースに、以下のような機能を追加していくことができます。

  • 複数ファイルのアップロード: FormData で複数のファイルを扱う実装
  • 進捗表示: XMLHttpRequest や Fetch の progress イベントを活用
  • リサイズ処理: アップロード前にクライアント側で画像をリサイズ
  • チャンク分割: 大きなファイルを分割してアップロード
  • クラウドストレージ連携: AWS S3 や Google Cloud Storage への直接アップロード

multipart/form-data の処理は一見複雑に見えますが、基本的な仕組みを理解し、適切な切り分け手順を知っていれば、確実に実装できるようになります。

この記事が、Next.js でファイルアップロード機能を実装する際の助けになれば幸いです。問題が発生した際は、本記事のチェックリストを参考に、一つずつ確認していってください。

関連リンク