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

La introducción de Next.js App Router ha cambiado significativamente los patrones de implementación de carga de archivos. La biblioteca multer, 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 FormData 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 "use server" que se pueden pasar directamente a la action 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 app/api/upload/route.ts. multer no es necesario; puedes obtener el estándar web FormData mediante request.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,
  },
};

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 upload.onprogress de XMLHttpRequest o 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>
  );
}

Cargar a Vercel Blob

En el entorno de Vercel, usar el paquete @vercel/blob 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 next.config.js. Sin embargo, si se implementa en Vercel, la configuración en vercel.json 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;

❓ Preguntas frecuentes

¿Puedo usar multer con el App Router de Next.js?
No: multer no funciona en los route handlers del App Router (route.ts). Usa request.formData() del estándar web para obtener el FormData y file.arrayBuffer() para leer el contenido. multer da por hecho el http.IncomingMessage de Node, mientras que el App Router usa el objeto Request estándar.
¿Cuál es el límite de subida de archivos en Vercel?
El cuerpo de una Vercel Function es de 4,5 MB por defecto. Los archivos mayores pasan por la subida de cliente de Vercel Blob o por una URL prefirmada de S3 para subir directamente. Las Server Actions permiten subir serverActions.bodySizeLimit en next.config.js, pero eso no levanta el límite de la propia infraestructura de Vercel.
¿Cómo muestro el progreso de subida en Next.js?
La Fetch API no informa del progreso de subida, así que usa XMLHttpRequest y su evento xhr.upload.onprogress, o una librería como axios para algo más cómodo. Lo mismo vale para las subidas directas con URL prefirmada de S3: envía el PUT desde el navegador con XHR y tendrás progreso.

Archivos de prueba para este artículo (gratis)