Cambiar tema

Guía completa de subida de archivos en Next.js: carga directa con URL pre-firmada en S3/Qiniu Cloud

Easton editorial illustration: island architecture model

El usuario pulsa el botón «Subir avatar» y elige una foto de 10 MB. La barra de progreso se queda en el 30 %. Cuarenta segundos después, el navegador muestra: «Request Entity Too Large».

Miré los logs de despliegue en Vercel y ese familiar error de «4MB body size limit» por tercera vez, maldiciendo en silencio los límites de la API de Next.js. Cuando empecé con la subida de archivos, pensé que bastaba con una API Route. La realidad me enseñó otra cosa: los archivos de los usuarios crecían, la memoria del servidor se agotaba y la velocidad de subida daba ganas de tirar el portátil.

Luego encontré una solución más elegante: carga directa a almacenamiento en la nube con URL pre-firmada. Los archivos ya no pasan por tu servidor; van directo a S3 o Qiniu Cloud. La velocidad triplica, la presión sobre el servidor es cero y el límite salta de 4 MB a 5 GB.

En este artículo te guío paso a paso. Aprenderás a configurar S3 y Qiniu Cloud, generar URLs pre-firmadas, gestionar el progreso de subida, optimizar imágenes y evitar todos los errores que yo cometí. El código son ejemplos completos listos para producción.

¿Por qué elegir la carga directa con URL pre-firmada?

Tres problemas graves del enfoque tradicional

Primero, veamos cómo funciona la subida tradicional: el usuario elige un archivo → lo sube a tu servidor Next.js → el servidor lo reenvía al almacenamiento en la nube. Suena razonable, pero en la práctica hay problemas por todas partes.

Problema 1: límites duros de la API de Next.js

El App Router de Next.js impone un límite estricto al cuerpo de la petición: 4 MB por defecto. Edge Runtime es aún más estricto: solo 1 MB. Puedes pensar en cambiar la configuración, pero plataformas como Vercel no lo permiten. Aunque pudieras subirlo a 10 o 50 MB, un vídeo HD del usuario seguiría fallando.

Problema 2: el servidor no aguanta la carga

¿Qué implica que el archivo pase por el servidor? El doble de memoria. Si un usuario sube 100 MB, tu servidor recibe esos 100 MB (memoria) y luego los reenvía a S3 (más memoria). ¿Diez usuarios a la vez? Tu instancia de 2 GB revienta.

Construí una comunidad de imágenes donde, en hora punta, la CPU del servidor llegaba al 90 % procesando reenvíos. Tras pasar a carga directa, la CPU bajó al 15 %. No es una optimización menor: es un salto cualitativo.

Problema 3: lento y mala experiencia

Si el archivo da la vuelta por el servidor, el recorrido es más largo. Usuario en Shenzhen, servidor en Silicon Valley, S3 en Singapur: Shenzhen → Silicon Valley → Singapur. Con URL pre-firmada: Shenzhen → Singapur. La ruta se acorta a la mitad y la velocidad sube sola.

¿Cómo funciona la URL pre-firmada?

En resumen, es un «pase temporal» que te da el servicio de almacenamiento. El flujo:

  1. El usuario pulsa subir; el frontend pide al servidor Next.js: «Quiero subir un archivo»
  2. El servidor contacta con S3: «Genera un enlace de subida válido 60 segundos»
  3. S3 devuelve una URL cifrada, por ejemplo https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890
  4. El frontend usa esa URL y envía el archivo con PUT directo a S3, sin pasar por tu servidor
  5. Al terminar, S3 devuelve la URL final del archivo

Lo bueno de este «pase temporal»: caduca (60 s), permisos mínimos (solo ese archivo) y sin exponer claves (el frontend no ve tu AWS Secret Key).

Comparación técnica

DimensiónSubida tradicional (vía servidor)Carga directa con URL pre-firmada
Límite de tamaño4 MB (Vercel/Netlify)5 GB (subida única en S3)
Memoria del servidorAlta (tamaño × 2)Cero
CPU del servidorAlta (reenvío)Muy baja (solo generar URL)
Velocidad de subidaLenta (salto extra)Rápida (CDN directo)
ConcurrenciaLimitada por el servidorIlimitada (lo aguanta el cloud)
SeguridadHay que exponer credenciales parcialesAutorización temporal con caducidad
5GB
Tamaño máximo de un solo archivo con URL pre-firmada: 1250 veces el límite de 4 MB de la API de Next.js
Source: Documentación oficial de AWS

La documentación de AWS indica que una URL pre-firmada admite hasta 5 GB por subida. Para archivos mayores, usa Multipart Upload; en teoría no hay límite superior.

S3 vs Qiniu Cloud: ¿cuál elegir?

Con la teoría clara, toca decidir: ¿S3 o Qiniu Cloud? He usado ambos; aquí van sus puntos fuertes.

Precio: paquete anual vs pago por uso

Qiniu Cloud apuesta por paquetes anuales. Cuota gratuita generosa: 10 GB de almacenamiento + 10 GB de tráfico de descarga al mes; suficiente para proyectos pequeños. Fuera de eso, precios escalonados: almacenamiento 0,148 CNY/GB/mes, tráfico CDN 0,29 CNY/GB.

Ejemplo: 1000 usuarios, 10 imágenes cada uno (~2 MB), 20 GB almacenados; 100 GB de tráfico de descarga al mes en Qiniu Cloud:

  • Almacenamiento: (20 GB − 10 GB gratis) × 0,148 CNY = 1,48 CNY
  • Tráfico: (100 GB − 10 GB gratis) × 0,29 CNY = 26,1 CNY
  • Total mensual: 27,58 CNY

AWS S3 es puro pago por uso. Sin cuota gratuita permanente (cuenta nueva: algo gratis el primer año). En us-east-1: almacenamiento 0,023 USD/GB/mes, tráfico 0,09 USD/GB.

Mismo escenario en S3:

  • Almacenamiento: 20 GB × 0,023 USD × 7 (CNY) ≈ 3,22 CNY
  • Tráfico: 100 GB × 0,09 USD × 7 ≈ 63 CNY
  • Total mensual: 66,22 CNY

S3 parece el doble de caro, pero el tráfico se puede optimizar con CloudFront CDN y la latencia global es más uniforme.

Velocidad de acceso en China: factor clave

Si tus usuarios están sobre todo en China, los nodos CDN de Qiniu Cloud están más densos y la velocidad es claramente mayor. En pruebas reales, imágenes desde Qiniu para usuarios en Shenzhen: 30–50 ms de latencia media; AWS S3 (incluso Tokio): 120–180 ms.

La diferencia: Qiniu tiene registro en China y puede usar nodos CDN nacionales; la mayoría de nodos S3 están fuera y los datos cruzan fronteras. Mercado internacional → S3; mercado doméstico chino → Qiniu Cloud suele ser más práctico.

Documentación y ecosistema: inglés vs chino

La documentación de AWS es muy completa, pero en inglés y con mucha jerga. Qiniu Cloud tiene documentación en chino clara y muchos ejemplos.

En ecosistema, S3 gana: Next.js, Vercel y librerías open source tratan S3 como ciudadano de primera clase. La comunidad de Qiniu es más pequeña; a veces hay que resolver cosas por tu cuenta.

Mi recomendación

Árbol de decisión:

  • Elige S3 si:

    • Tu producto es internacional y los usuarios están repartidos por el mundo
    • Ya usas otros servicios AWS (Lambda, RDS, etc.)
    • Necesitas funciones avanzadas (p. ej. Lambda para procesar imágenes al subir)
    • Tienes presupuesto y valoras ecosistema y estabilidad
  • Elige Qiniu Cloud si:

    • El 90 % de tus usuarios está en China
    • Eres un equipo startup con presupuesto ajustado
    • Quieres soporte en chino y no pelearte con documentación en inglés
    • La aceleración CDN es prioritaria

En mis proyectos: mercado doméstico → Qiniu Cloud; usuarios en el extranjero → S3. Conviven sin conflicto.

URL pre-firmada en S3 (App Router)

Directo al código. Cuatro pasos: entorno, API en servidor, componente cliente y procesado de imágenes.

Paso 1: preparar el entorno

Instala dos paquetes oficiales de AWS:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

En .env.local, añade tus credenciales AWS:

AWS_REGION=ap-southeast-1  # Región más cercana a tus usuarios
AWS_ACCESS_KEY_ID=tuAccessKey
AWS_SECRET_ACCESS_KEY=tuSecretKey
AWS_S3_BUCKET_NAME=my-app-uploads

¿De dónde salen? Consola AWS → crea un usuario IAM con permisos mínimos (solo subida al bucket indicado) → anota el Access Key. Crea el bucket en la consola S3 y elige región.

¡No olvides CORS! En la configuración del bucket S3, añade:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedOrigins": ["http://localhost:3000", "https://tudominio.com"],
    "ExposeHeaders": ["ETag"]
  }
]

Sin esto, el navegador lanza error CORS. ¿Cómo lo sé? Me pasó.

Paso 2: generar la URL pre-firmada en el servidor

Crea app/api/upload/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 s3Client = 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) {
  try {
    const { fileName, fileType } = await request.json();

    // Seguridad: solo imágenes
    if (!fileType.startsWith('image/')) {
      return NextResponse.json(
        { error: 'Solo se admiten formatos de imagen' },
        { status: 400 }
      );
    }

    // Nombre único para evitar sobrescrituras
    const key = `uploads/${Date.now()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    });

    // URL pre-firmada válida 60 segundos
    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 60,
    });

    const fileUrl = `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

    return NextResponse.json({ uploadUrl, fileUrl });
  } catch (error) {
    console.error('Error al generar URL pre-firmada:', error);
    return NextResponse.json(
      { error: 'Error del servidor' },
      { status: 500 }
    );
  }
}

Lógica central:

  1. Recibe nombre y tipo de archivo
  2. Comprueba que sea imagen (evita ejecutables)
  3. Genera key única con timestamp + nombre original
  4. Llama a getSignedUrl para la URL temporal
  5. Devuelve URL de subida y URL final de acceso

expiresIn: 60 — tras 60 s la URL caduca. Puedes usar 300 (5 min), pero no demasiado por seguridad.

Paso 3: componente de subida en el cliente

Crea components/FileUpload.tsx:

'use client';

import { useState } from 'react';

export default function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    setProgress(0);

    try {
      // 1. Pedir URL pre-firmada al servidor
      const response = await fetch('/api/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
        }),
      });

      const { uploadUrl, fileUrl: finalUrl } = await response.json();

      // 2. Subir con XMLHttpRequest para escuchar progreso
      await new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const percent = Math.round((e.loaded / e.total) * 100);
            setProgress(percent);
          }
        });

        xhr.addEventListener('load', () => {
          if (xhr.status === 200) {
            resolve(xhr.response);
          } else {
            reject(new Error('Error en la subida'));
          }
        });

        xhr.addEventListener('error', () => reject(new Error('Error de red')));

        xhr.open('PUT', uploadUrl);
        xhr.setRequestHeader('Content-Type', file.type);
        xhr.send(file);
      });

      setFileUrl(finalUrl);
      alert('¡Subida correcta!');
    } catch (error) {
      console.error(error);
      alert('Error en la subida, inténtalo de nuevo');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? `Subiendo ${progress}%` : 'Iniciar subida'}
      </button>

      {uploading && (
        <div className="mt-4 w-full bg-gray-200 rounded h-2">
          <div
            className="bg-blue-600 h-2 rounded transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
      )}

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">¡Subida correcta!</p>
          <img src={fileUrl} alt="Imagen subida" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

¿Por qué XMLHttpRequest y no fetch? Porque fetch no permite escuchar el progreso de subida. La API es antigua, pero en este escenario sigue siendo la mejor opción.

Detalles de UX:

  • Barra de progreso en tiempo real
  • Botón deshabilitado mientras sube (evita doble clic)
  • Vista previa de la imagen al terminar

Paso 4: procesado y optimización de imágenes

Tras subir, suele hacer falta comprimir. Dos enfoques:

Opción 1: precompresión en el cliente (la que recomiendo)

Instala la librería:

npm install browser-image-compression

Antes de subir, comprime:

import imageCompression from 'browser-image-compression';

const handleUpload = async () => {
  if (!file) return;

  const options = {
    maxSizeMB: 1,          // Máximo 1 MB
    maxWidthOrHeight: 1920, // Máximo 1920 px
    useWebWorker: true,     // Web Worker, no bloquea el hilo principal
  };

  const compressedFile = await imageCompression(file, options);

  // Sube compressedFile en lugar de file
  // ...
};

Ventajas: menos tiempo de subida, menos coste de almacenamiento S3 y menos tráfico CDN. Una foto de iPhone de 5 MB queda en ~500 KB; a simple vista no se nota.

Opción 2: procesado automático en servidor

Trigger Lambda en S3: al subir un archivo, Lambda genera miniaturas, comprime o añade marca de agua. Más potente, pero más complejo; para quien ya domina AWS.

Integración con Qiniu Cloud

La idea es similar a S3, pero la API difiere. Repaso rápido de los puntos clave.

Configurar Qiniu Cloud

Regístrate en Qiniu Cloud y crea un espacio de almacenamiento de objetos (Bucket). Anota:

  • AccessKey y SecretKey (centro personal → gestión de claves)
  • Nombre del Bucket
  • Dominio CDN (dominio de prueba asignado; en producción enlaza el tuyo)

SDK Node.js de Qiniu:

npm install qiniu

En .env.local:

QINIU_ACCESS_KEY=tuAccessKey
QINIU_SECRET_KEY=tuSecretKey
QINIU_BUCKET=nombreDeTuBucket
QINIU_DOMAIN=tuDominioCDN

Generar token de subida en el servidor

Qiniu no usa «URL pre-firmada», sino token de subida; el principio es el mismo.

Crea app/api/qiniu-upload/route.ts:

import qiniu from 'qiniu';
import { NextRequest, NextResponse } from 'next/server';

const mac = new qiniu.auth.digest.Mac(
  process.env.QINIU_ACCESS_KEY!,
  process.env.QINIU_SECRET_KEY!
);

export async function POST(request: NextRequest) {
  try {
    const { fileName } = await request.json();

    const key = `uploads/${Date.now()}-${fileName}`;

    const options = {
      scope: `${process.env.QINIU_BUCKET}:${key}`,
      expires: 3600, // Token válido 1 hora
      returnBody: JSON.stringify({
        key: '$(key)',
        hash: '$(etag)',
        url: `https://${process.env.QINIU_DOMAIN}/$(key)`,
      }),
    };

    const putPolicy = new qiniu.rs.PutPolicy(options);
    const uploadToken = putPolicy.uploadToken(mac);

    return NextResponse.json({
      token: uploadToken,
      key: key,
      domain: process.env.QINIU_DOMAIN,
    });
  } catch (error) {
    console.error('Error al generar token de Qiniu:', error);
    return NextResponse.json({ error: 'Error del servidor' }, { status: 500 });
  }
}

Diferencias con S3:

  • S3 devuelve URL completa; Qiniu devuelve token
  • S3 caduca a 60 s; Qiniu suele usar 3600 s (1 h)
  • returnBody define la respuesta tras subida correcta

Subida desde el cliente a Qiniu Cloud

El SDK JS oficial funciona; yo prefiero FormData, más ligero.

'use client';

import { useState } from 'react';

export default function QiniuUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);

    try {
      const response = await fetch('/api/qiniu-upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileName: file.name }),
      });

      const { token, key, domain } = await response.json();

      const formData = new FormData();
      formData.append('file', file);
      formData.append('token', token);
      formData.append('key', key);

      const uploadResponse = await fetch('https://upload.qiniup.com', {
        method: 'POST',
        body: formData,
      });

      const result = await uploadResponse.json();
      setFileUrl(`https://${domain}/${result.key}`);
      alert('¡Subida correcta!');
    } catch (error) {
      console.error(error);
      alert('Error en la subida');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? 'Subiendo...' : 'Subir a Qiniu Cloud'}
      </button>

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">¡Subida correcta!</p>
          <img src={fileUrl} alt="Imagen subida" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

Endpoint fijo de Qiniu: https://upload.qiniup.com. Usuarios sobre todo en el este de China: https://upload-z0.qiniup.com puede ir más rápido.

Procesado de imágenes

En Qiniu es más sencillo que en S3: sin Lambda, basta añadir parámetros a la URL.

Miniatura de 300 px de ancho sobre https://xxx.com/image.jpg:

https://xxx.com/image.jpg?imageView2/2/w/300

Comprimir por debajo de 100 KB:

https://xxx.com/image.jpg?imageMogr2/strip/quality/75

Es procesamiento de datos (fop): decenas de operaciones combinables. En S3 haría falta Lambda o un servicio tercero.

Comparación de código S3 vs Qiniu

PasoS3Qiniu Cloud
SDK servidor@aws-sdk/client-s3qiniu
AutorizaciónURL pre-firmadaToken de subida
EndpointURL del bucketupload.qiniup.com
MétodoPUT + streamFormData
ImágenesLambda o tercerosParámetros URL (fop)

En conjunto, la API de Qiniu encaja mejor con hábitos de desarrolladores en China; S3 es más potente con curva de aprendizaje más pronunciada.

Mejores prácticas para producción

Que el código funcione en local es una cosa; que aguante producción, otra. Errores que cometí y cómo evitarlos.

Seguridad: nunca filtrar claves

Error clásico: poner AWS Secret Key en el frontend. Gente lo ha hecho y ha recibido facturas de miles de dólares por subidas abusivas.

Lo correcto:

  1. Claves solo en variables de entorno del servidor (.env.local); nunca en Git
  2. Rol IAM con permisos mínimos: solo subida a S3, no borrado ni administración
  3. Ciclo de vida del bucket: borrar automáticamente temporales de más de 30 días

Ejemplo IAM mínimo:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl"],
      "Resource": "arn:aws:s3:::your-bucket-name/uploads/*"
    }
  ]
}

Solo permite subir a uploads/; el resto se deniega.

Validación de archivos antes de generar la URL:

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

if (!ALLOWED_TYPES.includes(fileType)) {
  return NextResponse.json({ error: 'Tipo de archivo no admitido' }, { status: 400 });
}

if (fileSize > MAX_SIZE) {
  return NextResponse.json({ error: 'Archivo demasiado grande' }, { status: 400 });
}

Si puedes, integra escaneo antivirus (p. ej. VirusTotal).

Rendimiento: compresión en cliente + lazy loading

En producción la compresión en cliente es obligatoria, no opcional.

Fotos de móvil de 5–10 MB directas desperdician tiempo y ancho de banda. Comprimir a menos de 1 MB: subida ~5× más rápida, ~80 % menos almacenamiento y menos tráfico.

const compressOptions = {
  maxSizeMB: 1,
  maxWidthOrHeight: 1920,
  useWebWorker: true,
  fileType: 'image/webp', // WebP reduce más el tamaño
};

Al mostrar, usa Image de Next.js:

import Image from 'next/image';

<Image
  src={fileUrl}
  alt="Imagen subida por el usuario"
  width={800}
  height={600}
  loading="lazy"
  placeholder="blur"
  blurDataURL="data:image/..." // Placeholder borroso
/>

La imagen carga al hacer scroll; la primera pantalla va más ligera.

UX: cola de subidas + reanudación

Varios archivos a la vez requieren cola y límite de concurrencia. Diez imágenes en paralelo bloquean el navegador.

Limitar concurrencia:

async function uploadQueue(files: File[], maxConcurrent = 3) {
  const results = [];
  for (let i = 0; i < files.length; i += maxConcurrent) {
    const batch = files.slice(i, i + maxConcurrent);
    const batchResults = await Promise.all(batch.map(uploadFile));
    results.push(...batchResults);
  }
  return results;
}

Reanudación:

  1. Partir archivos grandes (fragmentos de 5 MB)
  2. Guardar progreso en localStorage por fragmento
  3. Tras fallo o recarga, continuar desde el punto guardado

S3 Multipart Upload y Qiniu soportan subida por fragmentos. Implementación más larga; referencia: documentación Multipart Upload de AWS.

Mensajes de error claros:

catch (error) {
  let message = 'Error en la subida, inténtalo de nuevo';

  if (error.message.includes('NetworkError')) {
    message = 'Red inestable, comprueba la conexión';
  } else if (error.message.includes('403')) {
    message = 'Credencial caducada, recarga la página';
  } else if (error.message.includes('Too large')) {
    message = 'Archivo demasiado grande; elige uno menor de 10 MB';
  }

  setErrorMessage(message);
}

No te quedes en «Error en la subida»: explica el motivo y qué hacer.

Monitorización: logs y alertas

En producción registra logs para depurar.

Logs en servidor:

console.log(`[Upload] User: ${userId}, File: ${fileName}, Size: ${fileSize}`);

console.error(`[Upload Error]`, {
  user: userId,
  file: fileName,
  error: error.message,
  stack: error.stack,
});

En Vercel van al sistema de logs. En AWS, CloudWatch: tasa de fallos de subida, alerta si supera el 5 %, tamaño del bucket.

Frontend: Sentry para capturar errores de subida:

import * as Sentry from '@sentry/nextjs';

try {
  await uploadFile(file);
} catch (error) {
  Sentry.captureException(error, {
    tags: { feature: 'file-upload' },
    extra: { fileName, fileSize },
  });
  throw error;
}

Así ves cuántos usuarios fallan y qué errores son más frecuentes.

Resolución de problemas frecuentes

Problemas que cubren ~90 % de los fallos que he visto.

Problema 1: error CORS — «No ‘Access-Control-Allow-Origin’»

Síntoma: error rojo en consola; la petición se bloquea.

Causa: CORS del bucket S3 mal configurado o ausente.

Solución:

  1. Consola S3 → tu bucket
  2. «Permissions» → «CORS configuration»
  3. Pega:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

En producción no uses "*"; pon tu dominio, p. ej. ["https://tuapp.com"].

Qiniu también tiene CORS en la configuración del bucket.

Problema 2: URL pre-firmada caducada — 403 Forbidden

Síntoma: 403, «Access Denied» o «Request has expired».

Causas habituales:

  1. URL caducada (más de 60 s o el tiempo configurado)
  2. Reloj del servidor desincronizado
  3. Permisos IAM insuficientes

Solución:

  • Tiempo: comprueba date frente a hora estándar; más de 15 min de desfase invalida la firma
  • Caducidad: sube expiresIn a 300 (5 min)
  • Permisos: IAM debe incluir s3:PutObject y Resource correcto

Una vez en local iba bien y en Vercel daba 403: instancias serverless con reloj a veces desincronizado; lo arreglé alargando la caducidad.

Problema 3: timeout o bloqueo en archivos grandes

Síntoma: la barra se para al 50 % o timeout.

Causas:

  1. Red inestable
  2. Archivo muy grande (p. ej. vídeo 200 MB) en una sola petición
  3. Límites de timeout del navegador o Vercel

Solución:

  • Archivos pequeños (<100 MB): reintentos en cliente
  async function uploadWithRetry(url, file, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        return await upload(url, file);
      } catch (error) {
        if (i === maxRetries - 1) throw error;
        await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
      }
    }
  }
  
  • Archivos grandes (>100 MB): Multipart Upload
    • Fragmentos de 5 MB
    • Reintento por fragmento fallido
    • CompleteMultipartUpload al final

AWS y Qiniu documentan sus APIs de fragmentos (ETag, numeración).

Problema 4: subida OK pero no se puede acceder — 403 o 404

Síntoma: respuesta 200 al subir; al abrir la URL, error.

Causas:

  1. Bucket privado sin lectura pública
  2. URL mal construida
  3. Dominio CDN sin configurar o sin propagar

Solución:

  • S3 público: desactiva «Block all public access» y añade en Bucket Policy:
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "PublicReadGetObject",
        "Effect": "Allow",
        "Principal": "*",
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::your-bucket-name/*"
      }
    ]
  }
  
  • Qiniu: espacio «público» + dominio CDN enlazado

  • Comprueba la URL: imprime fileUrl y ábrela en el navegador; 404 → revisa bucket, región y key

La primera vez con S3 olvidé el Bucket Policy: una semana de quejas hasta encontrarlo.

Resumen

En una frase: no hagas pasar los archivos por tu servidor; súbelos directo al almacenamiento en la nube.

URL pre-firmada o token de subida evitan el límite de 4 MB de Next.js, permiten archivos grandes, cero presión en el servidor y mejor UX. S3 y Qiniu Cloud tienen perfiles distintos: internacional → S3; mercado chino → Qiniu.

La implementación no es compleja; importan los detalles:

  • Seguridad: claves protegidas, IAM mínimo, validación de tipos
  • Rendimiento: compresión en cliente (~80 % menos coste)
  • Experiencia: progreso, errores claros, cola de subidas
  • Monitorización: logs y alertas

He usado este esquema en tres proyectos en producción más de un año y millones de subidas. Los fallos típicos están en «Problemas frecuentes»; siguiendo la guía reduces mucho el riesgo.

El código del artículo es ejecutable tal cual. Si algo falla, revisa CORS e IAM primero: ~90 % de errores vienen de ahí.

Próximos pasos:

  • Arrastrar y soltar (react-dropzone)
  • Reanudación (Multipart Upload)
  • Vídeo y transcodificación (S3 + AWS MediaConvert)
  • Componente de subida con barra de progreso más vistosa

Subir archivos parece simple; hacerlo bien en producción, no tanto. Espero que esta guía te ahorre tiempo.

Flujo completo de subida de archivos Next.js con S3/Qiniu Cloud

Implementar desde cero carga directa con URL pre-firmada en Next.js App Router, con soporte S3 y Qiniu Cloud

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Preparación del entorno e instalación de dependencias

    **Esquema S3**:
    • Instalar AWS SDK: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
    • Variables de entorno: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME
    • Crear bucket S3 y configurar CORS (métodos PUT/POST)
    • IAM con permisos mínimos (solo s3:PutObject y s3:PutObjectAcl)

    **Esquema Qiniu Cloud**:
    • Instalar SDK: npm install qiniu
    • Variables: QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
    • Crear bucket y enlazar dominio CDN
    • Configurar CORS si hay peticiones cross-origin
  2. 2

    Step 2: API en el servidor

    **URL pre-firmada S3** (app/api/upload/route.ts):
    • S3Client y PutObjectCommand
    • Validar tipo (lista blanca) y tamaño (máx. 10 MB)
    • Nombre único: uploads/timestamp-nombreOriginal
    • getSignedUrl con caducidad 60 s
    • Devolver uploadUrl y fileUrl

    **Token Qiniu** (app/api/qiniu-upload/route.ts):
    • PutPolicy del SDK qiniu
    • scope en formato Bucket:key
    • returnBody para respuesta tras subida
    • uploadToken con caducidad 3600 s
    • Devolver token, key y domain
  3. 3

    Step 3: Componente de subida en el cliente

    **Selección y estado**:
    • useState para file, uploading, progress, fileUrl
    • input type="file" para elegir archivo

    **Flujo S3**:
    1. Pedir URL pre-firmada al servidor
    2. Subir con XMLHttpRequest (no fetch)
    3. Escuchar progress en xhr.upload
    4. PUT con Content-Type del archivo

    **Flujo Qiniu**:
    1. Pedir uploadToken al servidor
    2. FormData con file, token, key
    3. POST a https://upload.qiniup.com
    4. Construir URL final con key devuelta
  4. 4

    Step 4: Compresión de imágenes

    **Precompresión en cliente** (recomendado):
    • browser-image-compression
    • maxSizeMB: 1, maxWidthOrHeight: 1920
    • Web Worker para no bloquear UI
    • Preferir WebP
    • ~80 % menos almacenamiento y tráfico

    **Servidor** (opcional):
    • S3: trigger Lambda para miniaturas
    • Qiniu: parámetros URL (fop), p. ej. ?imageView2/2/w/300
  5. 5

    Step 5: Endurecimiento de seguridad en producción

    **Claves**:
    • Solo en .env.local del servidor, nunca en Git
    • IAM limitado a uploads/*
    • Nunca Secret Key en el frontend

    **Validación**:
    • Lista blanca image/jpeg, image/png, etc.
    • Límite de tamaño (p. ej. 10 MB)
    • Opcional: VirusTotal u otro escaneo

    **Costes**:
    • Ciclo de vida del bucket: borrar temporales &gt;30 días
    • CloudWatch sobre tamaño de almacenamiento
  6. 6

    Step 6: UX y manejo de errores

    **Experiencia de subida**:
    • Progreso en porcentaje en tiempo real
    • Botón deshabilitado mientras sube
    • Concurrencia máxima 3 en multiarchivo
    • Reanudación con Multipart Upload en archivos grandes

    **Errores**:
    • CORS: revisar configuración del bucket
    • 403: caducidad, IAM, hora del servidor
    • Timeout: reintentos (máx. 3) o fragmentos
    • Sin acceso: permisos de lectura pública y URL

    **Monitorización**:
    • Logs en servidor (userId, nombre, tamaño)
    • Sentry en frontend
    • CloudWatch para tasa de fallos

FAQ

¿Por qué recomendar URL pre-firmada en lugar de subir vía servidor?
Tres ventajas centrales:

• Superar límites: API Route de Next.js ~4 MB por defecto; URL pre-firmada hasta 5 GB en una subida
• Cero carga en servidor: archivo directo al cloud, sin memoria ni CPU extra, concurrencia prácticamente ilimitada
• Más velocidad: un salto menos en la ruta, subida ~2–3× más rápida

Enfoque tradicional: archivo al servidor (memoria), reenvío al cloud (más memoria), doble tráfico y riesgo de caída en picos.
¿S3 o Qiniu Cloud? ¿Cuáles son las diferencias principales?
Recomendación:

**S3**: producto internacional, presupuesto holgado, integración profunda AWS (Lambda, RDS), prioridad en estabilidad
**Qiniu Cloud**: usuarios sobre todo en China, startup con presupuesto ajustado, soporte en chino, CDN exigente

Diferencias:
• Precio: Qiniu 10 GB gratis y ~40 % más barato en muchos escenarios; S3 pago por uso sin tier gratis permanente
• Velocidad: China → Qiniu ~3–4× más rápido (30 ms vs 120 ms); extranjero → S3
• Ecosistema: S3 muy maduro; Qiniu comunidad más pequeña
• Imágenes: Qiniu con parámetros URL; S3 Lambda o terceros
¿Qué hacer si aparece error CORS al subir?
Falta configuración CORS en el bucket; el navegador bloquea la petición.

**S3**:
1. Consola S3 → Bucket → Permissions → CORS
2. AllowedMethods: ["PUT", "POST"] y AllowedOrigins: ["tu dominio"]
3. En producción no uses comodín "*"

**Qiniu Cloud**:
1. Configuración del bucket → CORS
2. Dominios y métodos permitidos
3. ExposeHeaders debe incluir ETag

Espera ~5 min, limpia caché del navegador y reintenta.
¿Por qué XMLHttpRequest y no fetch para subir archivos?
fetch no expone progreso de subida; no puedes mostrar barra en tiempo real.

Ventajas de XMLHttpRequest:
• xhr.upload.addEventListener('progress')
• e.loaded y e.total para el porcentaje
• API antigua pero óptima para subida de archivos

Sin barra de progreso, fetch sirve, pero la UX empeora (el usuario no sabe cuánto falta).
¿La compresión en cliente empeora la calidad de la imagen?
Con configuración razonable, a simple vista casi no se nota.

Datos de prueba:
• Foto iPhone 5 MB → ~500 KB (~90 % menos)
• maxSizeMB: 1 y quality: 0.8
• Sin diferencia clara en móvil u ordenador

Beneficios:
• Subida ~5× más rápida
• ~80 % menos almacenamiento
• Menos tráfico CDN
• Mejor en móvil

Portfolio fotográfico exigente: sube quality a 0.9 o omite compresión.
¿Por qué no puedo acceder al archivo tras subir correctamente?
Tres causas frecuentes:

**Permisos del bucket** (la más común):
• S3: falta s3:GetObject en Bucket Policy o «Block all public access» activo
• Qiniu: espacio privado → cambiar a público

**URL mal formada**:
• S3: https://bucket-name.s3.region.amazonaws.com/key
• Qiniu: https://cdn-domain/key

**CDN sin propagar**:
• Qiniu: 5–10 min tras enlazar dominio
• Prueba con dominio de test de Qiniu

Abre fileUrl en el navegador: 403 permisos, 404 ruta incorrecta.
¿Cómo subir archivos grandes (&gt;100 MB)?
Usa Multipart Upload por fragmentos para evitar timeout en una sola petición.

**Enfoque**:
1. Partir en fragmentos de 5 MB (Blob.slice)
2. Subir cada uno y guardar ETag
3. Reintentar fragmentos fallidos
4. CompleteMultipartUpload al final

**S3**: CreateMultipartUpload, UploadPart, CompleteMultipartUpload

**Qiniu**: mkblk, bput, mkfile

Implementación más larga; consulta el tutorial oficial Multipart Upload de AWS.

18 min de lectura · Publicado el: 7 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog