Saltar al contenido

Guía de implementación de carga de archivos en Next.js | App Router, API Route y compatibilidad con S3

Categoría: Next.js / React
Este artículo está disponible actualmente solo en japonés. Estamos trabajando en las traducciones.

La introducción de Next.js App Router ha cambiado significativamente los patrones de implementación de carga de archivos. La biblioteca <code>multer</code>, comúnmente utilizada en la era de Pages Router, no se puede usar directamente con Route Handlers de App Router. En su lugar, las implementaciones que utilizan la API estándar de web <code>FormData</code> se han convertido en la corriente principal. Este artículo explica sistemáticamente desde Server Actions y Route Handlers (route.ts) de App Router hasta barras de progreso del lado del cliente, e incluye cargas a Vercel Blob y AWS S3.

Browser FormData Server Action "use server" Route Handler app/api/.../route.ts Vercel Blob S3 R2 / GCS
Diagrama: flujo de subida en Next.js App Router (Server Actions / Route Handlers)

Carga de archivos mediante Server Actions en App Router

Las Server Actions son funciones de servidor con la directiva <code>"use server"</code> que se pueden pasar directamente a la <code>action</code> de un formulario. Funcionan sin JavaScript e integran bien con la mejora progresiva.

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

Manejo de FormData en Route Handlers (route.ts)

Cuando se usa como API, crea un Route Handler en <code>app/api/upload/route.ts</code>. <code>multer</code> no es necesario; puedes obtener el estándar web <code>FormData</code> mediante <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,
  },
};

Implementación de barra de progreso del lado del cliente

La API Fetch no puede recuperar el progreso de carga por defecto. Puedes usar el evento <code>upload.onprogress</code> de <code>XMLHttpRequest</code> o <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>
  );
}

Cargar a Vercel Blob

En el entorno de Vercel, usar el paquete <code>@vercel/blob</code> te permite cargar directamente al almacenamiento de objetos administrado por Vercel. También se proporciona una función 「carga de cliente」 para eludir el límite de 4.5MB de 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 });
  }
}

Carga en AWS S3 (Método de URL Presignada)

Para archivos grandes e infraestructura distinta de Vercel, usar Presigned URLs de S3 para cargar directamente desde el cliente a S3 es el enfoque más eficiente.

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

Configuración en next.config.js

En Next.js, puedes configurar los ajustes relacionados con la carga de archivos en <code>next.config.js</code>. Sin embargo, si se implementa en Vercel, la configuración en <code>vercel.json</code> tiene prioridad.

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

Archivos de prueba para este artículo (gratis)

  • → <a href="/ja/files/images/png/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Imagen PNG de Prueba (1MB)</a> — Para pruebas de verificación de tipo MIME y tamaño
  • → <a href="/ja/files/pdf/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Archivo de prueba PDF (1MB)</a> — para verificar la validación de cargas de PDF
  • → <a href="/ja/files/zip/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Archivo de prueba ZIP (1MB)</a> — Para validación de carga de S3 y formato de archivo

Artículos relacionados

  • → <a href="/ja/blog/vercel-upload-config/" class="text-primary-600 dark:text-primary-400 hover:underline">Configuración y Límites de Carga de Archivos en Vercel | Vercel Blob, Restricciones API, Soluciones Alternativas</a>
  • → <a href="/ja/blog/s3-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">Resumen de Límites de Carga de Archivos para AWS S3 y CloudFront</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Lista de Verificación de Implementación de Validación de Archivos en Formularios Web</a>