Skip to content

Next.js File Upload Implementation Guide | App Router, API Route, and S3 Support

Category: Next.js / React
This article is currently available in Japanese only. We are working on translations.

The introduction of Next.js App Router has significantly changed file upload implementation patterns. The <code>multer</code> library, commonly used during the Pages Router era, cannot be directly used with App Router's Route Handlers. Instead, implementations using the Web standard <code>FormData</code> API have become mainstream. This article systematically explains everything from App Router's Server Actions and Route Handlers (route.ts) to client-side progress bars, and covers uploads to Vercel Blob and AWS S3.

Browser FormData Server Action "use server" Route Handler app/api/.../route.ts Vercel Blob S3 R2 / GCS
Diagram: Next.js App Router upload flow (Server Actions / Route Handlers)

File Upload Using Server Actions in App Router

Server Actions are server functions with the <code>"use server"</code> directive that can be passed directly to a form's <code>action</code>. They work without JavaScript and integrate well with progressive enhancement.

// 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>
  );
}

FormData handling in Route Handlers (route.ts)

When using as an API, create a Route Handler in <code>app/api/upload/route.ts</code>. <code>multer</code> is not needed; you can retrieve the Web standard <code>FormData</code> via <code>request.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,
  },
};

Client-side Progress Bar Implementation

The Fetch API cannot retrieve upload progress by default. You can use <code>XMLHttpRequest</code>'s <code>upload.onprogress</code> event or <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>
  );
}

Uploading to Vercel Blob

In the Vercel environment, using the <code>@vercel/blob</code> package allows you to upload directly to object storage managed by Vercel. A 「client upload」 feature is also provided to bypass the 4.5MB limit of Vercel Function.

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 });
  }
}

Upload to AWS S3 (Presigned URL Method)

For large files and infrastructure other than Vercel, uploading directly to S3 from the client using S3 Presigned URLs is the most efficient approach.

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;
}

Configuration in next.config.js

In Next.js, you can configure file upload settings in <code>next.config.js</code>. However, if deployed on Vercel, the settings in <code>vercel.json</code> take precedence.

// 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;

Test files for this article (free)

  • → <a href="/ja/files/images/png/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">PNG Test Image (1MB)</a> — For MIME type and size verification testing
  • → <a href="/ja/files/pdf/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">PDF Test File (1MB)</a> — for verifying PDF upload validation
  • → <a href="/ja/files/zip/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">ZIP test file (1MB)</a> — For S3 upload and file format validation

Related articles

  • → <a href="/ja/blog/vercel-upload-config/" class="text-primary-600 dark:text-primary-400 hover:underline">Vercel File Upload Configuration and Limits | Vercel Blob, API Restrictions, Workarounds</a>
  • → <a href="/ja/blog/s3-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">Summary of File Upload Limits for AWS S3 and CloudFront</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Web Form File Validation Implementation Checklist</a>