Next.jsのファイルアップロード実装ガイド|App Router・API Route・S3対応
Next.js App Router の登場でファイルアップロードの実装パターンが大きく変わりました。Pages Router 時代に使われていた multer は App Router の Route Handlers では直接利用できず、Web 標準の FormData API を使った実装が主流になっています。本記事では App Router の Server Actions・Route Handlers(route.ts)・クライアントサイドのプログレスバーから、Vercel Blob や AWS S3 へのアップロードまで体系的に解説します。
App Router での Server Actions を使ったファイルアップロード
Server Actions は "use server" ディレクティブを付けたサーバー関数で、フォームの action に直接渡せます。JavaScript なしでも動作し、プログレッシブエンハンスメントとの相性が良い方法です。
// app/upload/actions.ts
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return { error: 'ファイルが選択されていません。' };
}
// ファイルサイズの検証(10MB 上限)
const MAX_SIZE = 10 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return { error: 'ファイルサイズは10MB以下にしてください。' };
}
// MIMEタイプの検証
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
return { error: '許可されていないファイル形式です。' };
}
// Buffer に変換してローカルに保存
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// ファイル名をサニタイズしてユニークな名前を生成
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${timestamp}_${safeName}`;
const uploadDir = join(process.cwd(), 'public', 'uploads');
await writeFile(join(uploadDir, filename), buffer);
return { success: true, filename, url: `/uploads/${filename}` };
}
// app/upload/page.tsx
import { uploadFile } from './actions';
export default function UploadPage() {
return (
<form action={uploadFile}>
<input type="file" name="file" accept="image/*,application/pdf" />
<button type="submit">アップロード</button>
</form>
);
}
Route Handlers(route.ts)での FormData 処理
API として利用する場合は app/api/upload/route.ts に Route Handler を作ります。multer は不要で、request.formData() で Web 標準の FormData を取得できます。
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'ファイルが見つかりません。' }, { status: 400 });
}
// ファイルサイズ検証
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: `ファイルサイズは ${MAX_SIZE / 1024 / 1024}MB 以下にしてください。` },
{ status: 413 }
);
}
// MIMEタイプ検証(クライアントの申告値は信頼せず、実際の内容で判定するのが理想)
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!allowedMimes.includes(file.type)) {
return NextResponse.json({ error: '許可されていないファイル形式です。' }, { status: 415 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// アップロードディレクトリの作成(存在しない場合)
const uploadDir = join(process.cwd(), 'public', 'uploads');
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true });
}
// ユニークなファイル名を生成
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = file.name.split('.').pop();
const filename = `upload-${uniqueSuffix}.${ext}`;
await writeFile(join(uploadDir, filename), buffer);
return NextResponse.json({
success: true,
filename,
url: `/uploads/${filename}`,
size: file.size,
type: file.type,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'アップロード中にエラーが発生しました。' }, { status: 500 });
}
}
// Vercel のデフォルトは 4.5MB のため、必要に応じて設定を変更
export const config = {
api: {
bodyParser: false,
},
};
クライアントサイドのプログレスバー実装
Fetch API は標準でアップロードの進捗を取得できません。XMLHttpRequest の upload.onprogress イベント、または ReadableStream を使う方法があります。
// components/UploadWithProgress.tsx
'use client';
import { useState } from 'react';
export default function UploadWithProgress() {
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [done, setDone] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
setUploading(true);
setProgress(0);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => { setUploading(false); setDone(true); };
xhr.open('POST', '/api/upload');
xhr.send(formData);
};
return (
<div>
<input type="file" onChange={handleUpload} />
{uploading && (
<div>
<div className="progress-bar" style={{ width: progress + '%' }} />
<span>{progress}%</span>
</div>
)}
{done && <p>アップロード完了</p>}
</div>
);
}
Vercel Blob へのアップロード
Vercel 環境では @vercel/blob パッケージを使うと、Vercel が管理するオブジェクトストレージに直接アップロードできます。Vercel Function の 4.5MB 制限を回避するために「クライアントアップロード」機能も提供されています。
npm install @vercel/blob
// app/api/upload/route.ts(Vercel Blob 使用)
import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'ファイルが見つかりません。' }, { status: 400 });
}
// Vercel Blob にアップロード(BLOB_READ_WRITE_TOKEN が必要)
const blob = await put(file.name, file, {
access: 'public', // 'public' or 'private'
addRandomSuffix: true, // ファイル名の衝突を防ぐ
});
return NextResponse.json({ url: blob.url, size: blob.size });
}
// 大容量ファイルのためにボディサイズ上限を引き上げ(Vercel では効果なし)
export const maxDuration = 60; // 秒
// クライアントアップロード(4.5MB超のファイルを Vercel Blob に直接送信)
// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody;
try {
const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => ({
allowedContentTypes: ['image/jpeg', 'image/png', 'application/pdf'],
maximumSizeInBytes: 50 * 1024 * 1024, // 50MB
}),
onUploadCompleted: async ({ blob, tokenPayload }) => {
console.log('アップロード完了:', blob.url);
},
});
return NextResponse.json(jsonResponse);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 400 });
}
}
AWS S3 へのアップロード(Presigned URL 方式)
大容量ファイルや Vercel 以外のインフラでは、S3 の Presigned URL を使ってクライアントから直接 S3 にアップロードする方法が最も効率的です。
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// app/api/presigned-url/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextRequest, NextResponse } from 'next/server';
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(request: NextRequest) {
const { filename, contentType, size } = await request.json();
// サーバー側でのバリデーション
const MAX_SIZE = 100 * 1024 * 1024; // 100MB
if (size > MAX_SIZE) {
return NextResponse.json({ error: 'ファイルが大きすぎます。' }, { status: 413 });
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'application/zip'];
if (!allowedTypes.includes(contentType)) {
return NextResponse.json({ error: '許可されていない形式です。' }, { status: 415 });
}
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_BUCKET_NAME!,
Key: key,
ContentType: contentType,
ContentLength: size,
});
// 15分間有効な署名付きURLを生成
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
return NextResponse.json({ presignedUrl, key });
}
// クライアントから S3 に直接アップロード
async function uploadToS3(file: File) {
// 1. サーバーから Presigned URL を取得
const res = await fetch('/api/presigned-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
const { presignedUrl, key } = await res.json();
// 2. S3 に直接 PUT(Vercel Function を経由しないため上限なし)
const uploadRes = await fetch(presignedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!uploadRes.ok) throw new Error('S3 アップロード失敗');
return key;
}
next.config.js での設定
Next.js では next.config.js でファイルアップロードに関連する設定が行えます。ただし Vercel にデプロイしている場合は vercel.json 側の設定が優先されます。
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 外部画像の許可(S3 のドメインなど)
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.s3.amazonaws.com',
},
{
protocol: 'https',
hostname: '*.public.blob.vercel-storage.com',
},
],
},
// Pages Router の API Route のボディサイズ上限(App Router には無効)
// App Router では Route Handler に対して個別に設定する
experimental: {
serverActions: {
bodySizeLimit: '10mb', // Server Actions のボディサイズ上限
},
},
};
module.exports = nextConfig;
この記事で使えるテストファイル(無料)
- → PNG テスト画像(1MB) — MIMEタイプ・サイズ検証のテストに
- → PDF テストファイル(1MB) — PDF アップロードのバリデーション確認に
- → ZIP テストファイル(1MB) — S3 アップロード・ファイル形式検証に