Next.js 文件上传实现指南|App Router・API Route・S3 对应
Next.js App Router 的推出大幅改变了文件上传的实现模式。Pages Router 时代使用的 <code>multer</code> 在 App Router 的 Route Handlers 中无法直接使用,使用 Web 标准 <code>FormData</code> API 的实现已成为主流。本文从 App Router 的 Server Actions・Route Handlers(route.ts)・客户端进度条,到上传到 Vercel Blob 和 AWS S3,进行了系统的说明。
在 App Router 中使用 Server Actions 进行文件上传
Server Actions 是带有 <code>"use server"</code> 指令的服务器函数,可以直接传递给表单的 <code>action</code>。它们可以在没有 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 时,在 <code>app/api/upload/route.ts</code> 中创建 Route Handler。不需要 <code>multer</code>,可以通过 <code>request.formData()</code> 获取 Web 标准的 <code>FormData</code>。
// 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 默认无法获取上传进度。可以使用 <code>XMLHttpRequest</code> 的 <code>upload.onprogress</code> 事件或 <code>ReadableStream</code>。
// 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 环境中,使用 <code>@vercel/blob</code> 包可以直接上传到 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(预签名 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 中,可以在 <code>next.config.js</code> 中进行文件上传相关的设置。但是,如果部署在 Vercel 上,<code>vercel.json</code> 中的设置会优先。
// 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;
本文中可用的测试文件(免费)
- → <a href="/ja/files/images/png/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">PNG 测试图像(1MB)</a> — 用于 MIME 类型·大小验证测试
- → <a href="/ja/files/pdf/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">PDF 测试文件(1MB)</a> — 用于验证 PDF 上传验证
- → <a href="/ja/files/zip/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">ZIP 测试文件 (1MB)</a> — 用于 S3 上传和文件格式验证
相关文章
- → <a href="/ja/blog/vercel-upload-config/" class="text-primary-600 dark:text-primary-400 hover:underline">Vercel 文件上传配置和限制 | Vercel Blob・API 限制・解决方案</a>
- → <a href="/ja/blog/s3-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">AWS S3・CloudFront 文件上传限制总结</a>
- → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Web 表单文件验证实现检查清单</a>