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-connect や multer といったミドルウェアを活用することが一般的でした。しかし、Route Handlers では Web 標準の Request/Response API を使用するため、アプローチが異なります。
| # | 項目 | Pages Router | Route Handlers |
|---|---|---|---|
| 1 | 実装場所 | pages/api/*.ts | app/api/*/route.ts |
| 2 | リクエスト型 | NextApiRequest | Request (Web API) |
| 3 | レスポンス型 | NextApiResponse | Response (Web API) |
| 4 | FormData 取得 | ミドルウェア必須 | 標準メソッド利用可 |
| 5 | bodyParser 設定 | 必要 | 不要 (自動処理) |
この違いを理解せずに実装すると、データが正しく受け取れない問題が発生します。
課題
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 に起因するかどうかは、ブラウザの開発者ツールで確認できます。
- ブラウザの開発者ツールを開く (F12 または Cmd+Option+I)
- Network (ネットワーク) タブを選択
- ファイルアップロードを実行
- リクエストを選択し、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 }
);
}
切り分け方法: サイズの段階的テスト
以下の手順でファイルサイズ制限を特定します。
- 小さいファイル (1KB) でテスト → 成功
- 中程度のファイル (1MB) でテスト → 成功 or 失敗
- 大きいファイル (5MB) でテスト → 失敗
- 制限値を特定し、設定を調整
| # | ファイルサイズ | 結果 | 判定 |
|---|---|---|---|
| 1 | 100 KB | 成功 | OK |
| 2 | 1 MB | 成功 | OK |
| 3 | 5 MB | 失敗 | 制限は 1-5MB の間 |
| 4 | 3 MB | 失敗 | 制限は 1-3MB の間 |
| 5 | 2 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() メソッドを使用してバイナリデータを取得します。
切り分けチェックリスト
問題が発生した際に確認すべき項目を整理しました。上から順番にチェックしていくことで、効率的に原因を特定できます。
| # | チェック項目 | 確認方法 | 該当する課題 |
|---|---|---|---|
| 1 | Content-Type に boundary があるか | ブラウザの Network タブ | 課題 1 |
| 2 | request.formData() を使用しているか | コード確認 | 課題 2 |
| 3 | FormData が正しくパースされるか | console.log で確認 | 課題 2 |
| 4 | ファイルサイズは制限内か | file.size の値確認 | 課題 3 |
| 5 | File インスタンスの型チェックをしているか | instanceof File 確認 | 課題 4 |
| 6 | null チェックを実施しているか | 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 | ファイル未選択 | ファイル選択せずに送信 | クライアント側でエラー |
| 5 | Content-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 でファイルアップロード機能を実装する際の助けになれば幸いです。問題が発生した際は、本記事のチェックリストを参考に、一つずつ確認していってください。
関連リンク
articleNext.js の Route Handlers で multipart/form-data が受け取れない問題の切り分け術
articleNext.js Server Components 時代のデータ取得戦略:fetch キャッシュと再検証の新常識
articleNext.js の 観測可能性入門:OpenTelemetry/Sentry/Vercel Analytics 連携
articleNext.js でドキュメントポータル:MDX/全文検索/バージョン切替の設計例
articleNext.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込
articleRedis 使い方:Next.js で Cache-Tag と再検証を実装(Edge/Node 両対応)
articleNotebookLM 活用事例:営業提案書の下書きと顧客要件の整理を自動化
articleGrok RAG 設計入門:社内ドキュメント検索を高精度にする構成パターン
articlegpt-oss 運用監視ダッシュボード設計:Prometheus/Grafana/OTel で可観測性強化
articleNode.js 標準テストランナー完全理解:`node:test` がもたらす新しい DX
articleNext.js の Route Handlers で multipart/form-data が受け取れない問題の切り分け術
articleMCP サーバー で社内ナレッジ検索チャットを構築:権限制御・要約・根拠表示の実装パターン
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来