Next.js 파일 업로드 구현 가이드|App Router・API Route・S3 대응
Next.js App Router의 등장으로 파일 업로드 구현 패턴이 크게 변했습니다. Pages Router 시대에 사용되던 <code>multer</code>는 App Router의 Route Handlers에서 직접 사용할 수 없으며, 웹 표준 <code>FormData</code> API를 사용한 구현이 주류가 되었습니다. 본 글에서는 App Router의 Server Actions・Route Handlers(route.ts)・클라이언트 측 진행률 표시줄부터 Vercel Blob과 AWS S3로의 업로드까지 체계적으로 설명합니다.
App Router에서 Server Actions를 사용한 파일 업로드
<code>"use server"</code> 지시자가 붙은 Server Actions는 서버 함수로, 폼의 <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로의 업로드(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에서는 <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">웹 폼 파일 검증 구현 체크리스트</a>