Guia completo de upload de arquivos no Next.js: S3 e Qiniu Cloud com URL preassinada

O usuário clica no botao “Enviar avatar” e escolhe uma foto de 10 MB. A barra de progresso chega a 30% e trava. Quarenta segundos depois, o navegador mostra o erro: “Request Entity Too Large”.
Eu fiquei olhando os logs de deploy da Vercel, vendo aquele conhecido erro “4MB body size limit”, e pela terceira vez xinguei por dentro a limitacao das APIs do Next.js. Quando comecei a implementar upload de arquivos, achei que uma API Route resolveria tudo. A realidade cobrou a conta: os arquivos dos usuários ficaram cada vez maiores, a memória do servidor apertou e a velocidade de upload ficou lenta a ponto de dar vontade de fechar o notebook.
Depois encontrei um caminho mais elegante: upload direto para armazenamento em nuvem com URL preassinada. O arquivo do usuário não passa mais pelo seu servidor; ele vai direto para o S3 ou para a Qiniu Cloud. A velocidade pode triplicar, a pressão no servidor cai para zero e o limite de tamanho salta de 4 MB para 5 GB.
Este artigo mostra, passo a passo, como implementar essa solucao. Voce vai ver como configurar S3 e Qiniu Cloud, gerar URL preassinada, tratar progresso de upload, otimizar imagens e evitar as armadilhas que eu ja pisei. Os exemplos de código sao completos e prontos para produção.
Por que escolher upload direto com URL preassinada?
Os tres problemas graves do modelo tradicional
Antes, vale olhar como o upload tradicional funciona: o usuário escolhe um arquivo -> envia para o seu servidor Next.js -> o servidor repassa para o armazenamento em nuvem. Parece razoável, mas na prática acumula problema.
Problema 1: a API do Next.js tem limite duro
O App Router do Next.js tem limites rigidos para o tamanho do corpo da requisição: 4 MB por padrão. O Edge Runtime e ainda mais apertado, com 1 MB. Voce pode pensar em aumentar esse limite na configuração, mas plataformas como a Vercel simplesmente não deixam. Mesmo que você consiga subir para 10 MB ou 50 MB, basta o usuário enviar um video em alta definicao para tudo quebrar.
Problema 2: o servidor não aguenta
Quando o arquivo passa pelo servidor, o que acontece? A memória dobra. Se o usuário envia um arquivo de 100 MB, seu servidor primeiro precisa receber esses 100 MB, ocupando memória, e depois repassar ao S3, ocupando memória de novo. Dez usuários subindo arquivos ao mesmo tempo? Uma instancia com 2 GB de memória pode cair direto.
Eu ja trabalhei em uma comunidade de imagens em que, no pico, a CPU do servidor chegava a 90%, quase tudo gasto com repasse de arquivos. Depois de migrar para upload direto, o uso de CPU caiu para 15%. Isso não e uma otimização pequena; e uma mudanca de categoria.
Problema 3: e lento e a experiência fica ruim
Quando o arquivo passa pelo servidor, ele da uma volta a mais. O usuário está em Shenzhen, seu servidor está no Vale do Silicio e o S3 está em Singapura. O caminho vira: Shenzhen -> Vale do Silicio -> Singapura. Com URL preassinada, fica: Shenzhen -> Singapura. O caminho encurta pela metade, entao a velocidade melhora naturalmente.
Como a URL preassinada funciona?
Em termos simples, a URL preassinada e um “passe temporario” emitido pelo servico de armazenamento em nuvem. O fluxo e este:
- O usuário clica em upload, e o frontend pede ao servidor Next.js: “quero enviar um arquivo”.
- O servidor chama o S3: “gere um link de upload valido por 60 segundos”.
- O S3 devolve uma URL assinada, por exemplo
https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890. - O frontend recebe essa URL e envia o arquivo direto ao S3 com uma requisição PUT, sem passar pelo seu servidor.
- Quando o upload termina, o S3 retorna o endereco final do arquivo.
O ponto forte desse “passe temporario” está em tres coisas: tem limite de tempo, expirando automaticamente depois de 60 segundos; tem permissão minima, permitindo subir apenas aquele arquivo; e não expoe chaves, porque o frontend nunca ve sua AWS Secret Key.
Comparacao tecnica
Colocando as duas abordagens lado a lado, a diferença fica visivel de cara:
| Dimensao | Upload tradicional, passando pelo servidor | Upload direto com URL preassinada |
|---|---|---|
| Limite de tamanho | 4 MB, em Vercel/Netlify | 5 GB, upload único no S3 |
| Uso de memória do servidor | Alto, tamanho do arquivo x 2 | Zero |
| Uso de CPU do servidor | Alto, processando repasse | Muito baixo, so gera URL |
| Velocidade de upload | Lenta, com uma etapa a mais | Rapida, conexão direta ao CDN |
| Concorrencia | Limitada pela configuração do servidor | Praticamente ilimitada, bancada pelo armazenamento em nuvem |
| Seguranca | Pode exigir exposicao parcial de credenciais | Autorizacao temporária e expiracao automatica |
A documentação oficial da AWS deixa claro: uma unica URL preassinada aceita upload de arquivo de até 5 GB. Se precisar de arquivos maiores, use Multipart Upload; na prática, o limite deixa de ser esse ponto.
S3 vs Qiniu Cloud: como escolher?
Agora que o principio tecnico está claro, vem a pergunta prática: usar S3 ou Qiniu Cloud? Eu ja usei os dois, entao vale separar os pontos principais.
Preço: pacote anual vs cobranca sob demanda
Qiniu Cloud segue uma linha mais parecida com “pacote”. A franquia gratuita e honesta: 10 GB de armazenamento por mes mais 10 GB de tráfego de download, suficiente para pequenos projetos pessoais. O excedente usa preco em degraus: armazenamento a 0,148 yuan/GB/mes e tráfego de CDN a 0,29 yuan/GB.
Fazendo a conta: seu app tem 1000 usuários; cada usuário envia 10 imagens, com media de 2 MB; isso da 20 GB de armazenamento. O tráfego mensal de download fica em 100 GB. O custo na Qiniu Cloud:
- Armazenamento: (20 GB - 10 GB grátis) x 0,148 yuan = 1,48 yuan
- Trafego: (100 GB - 10 GB grátis) x 0,29 yuan = 26,1 yuan
- Total mensal: 27,58 yuan
AWS S3 e cobranca sob demanda pura. Nao ha a mesma franquia gratuita, embora contas novas tenham alguma gratuidade no primeiro ano, mas o preco unitario e flexivel. Na regiao us-east-1, por exemplo: armazenamento a US$ 0,023/GB/mes e tráfego a US$ 0,09/GB.
No mesmo cenário, o custo do S3:
- Armazenamento: 20 GB x US$ 0,023 x 7, em conversao aproximada para yuan, cerca de 3,22 yuan
- Trafego: 100 GB x US$ 0,09 x 7, cerca de 63 yuan
- Total mensal: 66,22 yuan
De primeira, o S3 parece custar o dobro. Mas lembre que o tráfego do S3 pode ser otimizado com CloudFront CDN, e a velocidade global tende a ser mais equilibrada.
Velocidade na China: isso pesa muito
Se seus usuários estão principalmente na China, a Qiniu Cloud tem uma distribuicao mais densa de nos CDN locais e a diferença aparece. Em testes que fiz, usuários em Shenzhen acessavam imagens na Qiniu Cloud com latencia media de 30 a 50 ms. Acessando AWS S3, mesmo em no de Toquio, a latencia ficava em 120 a 180 ms.
Onde está a diferença? A Qiniu Cloud tem operação local na China e usa nos de CDN internos. O S3 da AWS fica majoritariamente fora da China, entao os dados cruzam fronteiras. Para mercado externo, o S3 leva vantagem; para foco na China, Qiniu Cloud e mais pragmatica.
Documentacao e ecossistema: ingles vs chinês
A documentação da AWS e muito completa, mas e toda em ingles, cheia de termos, e pode assustar quem está comecando. A documentação em chinês da Qiniu Cloud e bem clara e traz muitos exemplos de código.
Em ecossistema, o S3 ganha com folga. Next.js, Vercel e varias bibliotecas open source tratam S3 como cidadao de primeira classe. A comunidade da Qiniu Cloud e menor; quando surgir problema, talvez você precise investigar mais por conta propria.
Minha sugestao de escolha
Use está arvore de decisao:
-
Escolha S3 se você:
- está criando um produto internacional, com usuários pelo mundo
- ja usa outros servicos da AWS, como Lambda ou RDS
- precisa de recursos mais fortes, como processar imagens automaticamente com Lambda
- tem orçamento e valoriza ecossistema e estabilidade
-
Escolha Qiniu Cloud se você:
- tem 90% dos usuários na China
- trabalha em uma equipe pequena, com orçamento apertado
- quer suporte tecnico em chinês e não quer depender de documentação em ingles
- precisa de aceleracao CDN forte no mercado local
Nos meus projetos, uso Qiniu Cloud quando o foco e China e S3 quando ha usuários fora. Saber configurar os dois não conflita.
URL preassinada do S3 na prática, com App Router
Sem enrolar: vamos direto ao código. Vou dividir o fluxo em quatro partes: ambiente, API no servidor, componente no cliente e processamento de imagem.
Etapa 1: preparar o ambiente
Instale dois pacotes oficiais da AWS:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Depois adicione suas credenciais da AWS em .env.local:
AWS_REGION=ap-southeast-1 # escolha a regiao mais perto dos usuários
AWS_ACCESS_KEY_ID=suaAccessKey
AWS_SECRET_ACCESS_KEY=suaSecretKey
AWS_S3_BUCKET_NAME=my-app-uploads
De onde vem esses valores? Entre no console da AWS, crie um usuário IAM com permissão minima, apenas upload para um Bucket especifico, e anote a Access Key. O Bucket e criado no console do S3; escolha uma regiao e pronto.
Nao esqueca a configuração de CORS. Entre nas configuracoes do S3 Bucket e adicione está regra:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedOrigins": ["http://localhost:3000", "https://seu-domínio.com"],
"ExposeHeaders": ["ETag"]
}
]
Sem isso, o navegador retorna erro de CORS. Sei disso porque ja perdi tempo aqui.
Etapa 2: gerar a URL preassinada no servidor
Crie 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();
// Verificacao de segurança: aceitar apenas imagens
if (!fileType.startsWith('image/')) {
return NextResponse.json(
{ error: 'Apenas formatos de imagem sao aceitos' },
{ status: 400 }
);
}
// Gerar um nome único e evitar sobrescrita
const key = `uploads/${Date.now()}-${fileName}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME!,
Key: key,
ContentType: fileType,
});
// Gerar URL preassinada válida por 60 segundos
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 60,
});
// Retornar URL de upload e endereco final do arquivo
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('Falha ao gerar URL preassinada:', error);
return NextResponse.json(
{ error: 'Erro do servidor' },
{ status: 500 }
);
}
}
A lógica central desse trecho:
- Recebe nome e tipo do arquivo.
- Confere se e imagem, evitando upload de executáveis.
- Usa timestamp mais nome original para gerar uma key unica.
- Chama
getSignedUrlpara gerar uma URL temporária. - Retorna a URL de upload e o endereco final de acesso.
Repare em expiresIn: 60: depois de 60 segundos, a URL deixa de funcionar. Voce pode mudar para 300, ou 5 minutos, mas não recomendo deixar muito longo. Seguranca vem primeiro.
Etapa 3: componente de upload no cliente
Crie 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 a URL preassinada ao 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. Usar XMLHttpRequest para enviar e acompanhar o progresso
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('Falha no upload'));
}
});
xhr.addEventListener('error', () => reject(new Error('Erro de rede')));
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
setFileUrl(finalUrl);
alert('Upload concluído!');
} catch (error) {
console.error(error);
alert('Falha no upload, tente novamente');
} finally {
setUploading(false);
}
};
return (
<div className="max-w-md mx-auto p-6">
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.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 ? `Enviando ${progress}%` : 'Iniciar upload'}
</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">Upload concluído!</p>
<img src={fileUrl} alt="Imagem enviada" className="mt-2 max-w-full" />
</div>
)}
</div>
);
}
Por que XMLHttpRequest e não fetch? Porque a fetch API não permite acompanhar o progresso de upload. Eu sei que XMLHttpRequest parece antigo, mas nesse cenário ele ainda e a melhor escolha.
Detalhes de experiência:
- a barra mostra a porcentagem em tempo real
- o botao fica desativado durante o upload, evitando clique duplicado
- ao terminar, a imagem aparece automaticamente em pre-visualizacao
Etapa 4: processamento e otimização de imagens
Imagens enviadas normalmente precisam de compressão. Ha duas abordagens.
Solucao 1: comprimir no cliente, minha preferida.
Instale uma biblioteca:
npm install browser-image-compression
Adicione a compressão antes do upload:
import imageCompression from 'browser-image-compression';
const handleUpload = async () => {
if (!file) return;
// Comprimir imagem
const options = {
maxSizeMB: 1, // Maximo de 1 MB
maxWidthOrHeight: 1920, // Largura/altura maxima de 1920 px
useWebWorker: true, // Usa Web Worker e não trava a thread principal
};
const compressedFile = await imageCompression(file, options);
// Depois, use compressedFile no lugar de file para o upload
// ...
};
Qual o ganho? Menos tempo de upload, menor custo de armazenamento no S3 e menos custo de tráfego no CDN. Em um teste meu, uma foto de 5 MB tirada no iPhone caiu para 500 KB, sem diferença perceptivel a olho nu.
Solucao 2: processamento automatico no servidor
Use gatilhos Lambda do S3. Sempre que um arquivo cair no Bucket, uma função Lambda gera miniaturas, comprime a imagem, aplica marca-d’agua e assim por diante. Essa abordagem e mais poderosa, mas a configuração e mais complexa. Faz mais sentido para quem ja tem alguma experiência com AWS.
Integracao com Qiniu Cloud
A ideia da Qiniu Cloud e parecida com S3, mas a API muda um pouco. Vou passar pelos passos principais e focar nas diferencas.
Configurar Qiniu Cloud
Primeiro, registre uma conta no site da Qiniu Cloud e crie um espaco de armazenamento de objetos, ou Bucket. Guarde estas informações:
- AccessKey e SecretKey, em central do usuário -> gerenciamento de chaves
- Nome do Bucket
- Dominio de CDN, que a Qiniu Cloud fornece como domínio de teste; em produção, vincule seu próprio domínio
Instale o SDK Node.js da Qiniu Cloud:
npm install qiniu
Adicione a configuração em .env.local:
QINIU_ACCESS_KEY=suaAccessKey
QINIU_SECRET_KEY=suaSecretKey
QINIU_BUCKET=nomeDoSeuBucket
QINIU_DOMAIN=seuDominioDeCDN
Gerar token de upload no servidor
Na Qiniu Cloud, o nome não e URL preassinada, mas credencial de upload (Token). O principio e o mesmo.
Crie 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();
// Gerar nome único para o arquivo
const key = `uploads/${Date.now()}-${fileName}`;
const options = {
scope: `${process.env.QINIU_BUCKET}:${key}`,
expires: 3600, // Token valido por 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('Falha ao gerar Token da Qiniu Cloud:', error);
return NextResponse.json({ error: 'Erro do servidor' }, { status: 500 });
}
}
As diferencas para o S3:
- S3 retorna uma URL completa; Qiniu Cloud retorna um Token
- S3 costuma usar expiracao de 60 segundos; Qiniu Cloud costuma usar 3600 segundos, ou 1 hora
returnBodyda Qiniu Cloud define a estrutura dos dados retornados depois do upload
Enviar do cliente para Qiniu Cloud
A Qiniu Cloud recomenda usar o JS SDK oficial, mas eu prefiro FormData direto: fica mais leve.
'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 {
// 1. Obter o Token de upload
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();
// 2. Enviar para Qiniu Cloud
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('Upload concluído!');
} catch (error) {
console.error(error);
alert('Falha no upload');
} finally {
setUploading(false);
}
};
return (
<div className="max-w-md mx-auto p-6">
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.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 ? 'Enviando...' : 'Enviar para Qiniu Cloud'}
</button>
{fileUrl && (
<div className="mt-4">
<p className="text-sm text-gray-600">Upload concluído!</p>
<img src={fileUrl} alt="Imagem enviada" className="mt-2 max-w-full" />
</div>
)}
</div>
);
}
O endpoint de upload da Qiniu Cloud e fixo: https://upload.qiniup.com. Se seus usuários estão principalmente no leste da China, use https://upload-z0.qiniup.com, que costuma ser mais rápido.
Processamento de imagens
O processamento de imagens da Qiniu Cloud e muito mais simples que no S3: não precisa de Lambda, basta adicionar parametros a URL.
Por exemplo, se a URL original e https://xxx.com/image.jpg e você quer gerar uma miniatura com largura de 300 px:
https://xxx.com/image.jpg?imageView2/2/w/300
Se quiser comprimir para menos de 100 KB:
https://xxx.com/image.jpg?imageMogr2/strip/quality/75
Isso se chama processamento de dados (fop). A Qiniu Cloud oferece dezenas de operacoes de imagem que podem ser combinadas. No S3, para chegar ao mesmo resultado, você precisaria configurar Lambda ou contratar servico de terceiros. Da bem mais trabalho.
Comparacao de código entre S3 e Qiniu Cloud
| Etapa | S3 | Qiniu Cloud |
|---|---|---|
| SDK no servidor | @aws-sdk/client-s3 | qiniu |
| Forma de autorização | URL preassinada | Token de upload |
| Endpoint de upload | URL do próprio Bucket | upload.qiniup.com |
| Metodo de upload | Requisicao PUT + stream do arquivo | FormData |
| Processamento de imagem | Lambda ou servico de terceiros | Parametros de URL, fop |
No geral, a API da Qiniu Cloud combina mais com o hábito de desenvolvedores chineses, a documentação e clara e o inicio e rápido. O S3 e mais poderoso, mas a curva de aprendizado e mais inclinada.
Boas práticas de produção
Fazer o código rodar e uma coisa; fazer rodar com estabilidade em produção e outra. Aqui vao alguns problemas que ja encontrei e as solucoes correspondentes.
Seguranca: nunca vaze chaves
O erro mais fácil de cometer: colocar AWS Secret Key no código do frontend. Tem gente que faz isso de verdade e depois recebe uma conta de milhares de dólares porque alguem usou a chave para subir arquivos sem parar.
Forma correta:
- Chaves ficam somente nas variáveis de ambiente do servidor, como
.env.local, e nunca entram no Git. - Use papel ou usuário IAM com permissão restrita: apenas upload no S3, sem permissão de apagar ou administrar.
- Configure política de ciclo de vida no Bucket para apagar automaticamente arquivos temporários com mais de 30 dias, evitando custos fora de controle.
Exemplo de IAM com permissão minima:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
"Resource": "arn:aws:s3:::your-bucket-name/uploads/*"
}
]
}
Essa política permite apenas upload para o diretório uploads/; qualquer outra operação e recusada.
Validacao de arquivo também e obrigatória. Antes de gerar a URL preassinada no servidor, confira tipo e tamanho:
// Politica de lista permitida: aceitar apenas estes tipos
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 arquivo não aceito' }, { status: 400 });
}
if (fileSize > MAX_SIZE) {
return NextResponse.json({ error: 'Arquivo grande demais' }, { status: 400 });
}
Se fizer sentido para o seu produto, integre uma API de antivirus, como VirusTotal, para evitar que usuários enviem arquivos maliciosos.
Desempenho: compressão no cliente + carregamento lazy
Eu ja citei compressão de imagem no cliente, mas vale reforcar: em produção, isso e obrigatorio, não opcional.
O motivo e simples: fotos tiradas no celular facilmente tem 5 a 10 MB. Enviar isso cru desperdiça tempo e tráfego. Ao comprimir para menos de 1 MB, o upload fica 5 vezes mais rápido, o custo de armazenamento cai 80% e o tráfego de download também diminui. E um ganho triplo.
// Configuracao recomendada de compressão
const compressOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
fileType: 'image/webp', // Preferir WebP, que costuma ser menor
};
Na exibição da imagem, use o componente Image do Next.js para ter lazy loading e otimização responsiva automaticamente:
import Image from 'next/image';
<Image
src={fileUrl}
alt="Imagem enviada pelo usuário"
width={800}
height={600}
loading="lazy"
placeholder="blur"
blurDataURL="data:image/..." // Fornece placeholder borrado
/>
Assim, a imagem so carrega quando o usuário rola até ela, e a primeira tela abre bem mais rápido.
Experiencia do usuário: fila de upload + retomada
Se seu app aceita upload de vários arquivos, você precisa de um gerenciador de fila para limitar a concorrencia. Enviar 10 imagens ao mesmo tempo pode travar o navegador e deixar a experiência ruim.
Limitar concorrencia:
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;
}
Ideia para retomada de upload:
- Divida arquivos grandes em partes, por exemplo 5 MB cada.
- Grave o progresso de cada parte em localStorage.
- Se o upload falhar ou o usuário atualizar a pagina, leia o progresso e continue do ponto certo.
A API Multipart Upload do S3 oferece suporte nativo a upload em partes, e a Qiniu Cloud tem recurso parecido. A implementação e mais complexa; aqui fica a referencia: documentação de Multipart Upload da AWS.
Mensagens de erro precisam ser claras:
catch (error) {
let message = 'Falha no upload, tente novamente';
if (error.message.includes('NetworkError')) {
message = 'Rede instável, verifique sua conexão';
} else if (error.message.includes('403')) {
message = 'A credencial de upload expirou, atualize a pagina';
} else if (error.message.includes('Too large')) {
message = 'Arquivo grande demais, escolha um arquivo menor que 10 MB';
}
setErrorMessage(message);
}
Nao mostre apenas “falha no upload”. Diga ao usuário por que falhou e como resolver.
Monitoramento: logs + alertas
Em produção, registre logs. Isso facilita muito a investigação.
Logs no servidor:
// Registrar ao gerar a URL preassinada
console.log(`[Upload] User: ${userId}, File: ${fileName}, Size: ${fileSize}`);
// Registrar detalhes quando o upload falhar
console.error(`[Upload Error]`, {
user: userId,
file: fileName,
error: error.message,
stack: error.stack,
});
Na Vercel, esses logs sao enviados automaticamente ao sistema de logs deles. Se você usa AWS, recomendo configurar CloudWatch:
- monitorar a taxa de falha de uploads no S3
- criar alerta: se a taxa de falha passar de 5%, enviar email
- monitorar o tamanho armazenado no Bucket para evitar custos fora de controle
Monitoramento no frontend pode usar Sentry para capturar automaticamente erros relacionados a upload:
import * as Sentry from '@sentry/nextjs';
try {
await uploadFile(file);
} catch (error) {
Sentry.captureException(error, {
tags: { feature: 'file-upload' },
extra: { fileName, fileSize },
});
throw error;
}
Assim você consegue ver quantos usuários encontraram problemas de upload, quais erros sao mais comuns e onde otimizar primeiro.
Diagnostico de problemas comuns
Esta parte lista problemas frequentes que ja encontrei. Ela cobre boa parte das armadilhas do dia a dia.
Problema 1: erro de CORS - “No ‘Access-Control-Allow-Origin’”
Sintoma: o console do navegador mostra erro em vermelho, e a requisição de upload e bloqueada.
Causa: a configuração de CORS do S3 Bucket está ausente ou incorreta. Muita gente cria o Bucket e esquece de configurar CORS; o navegador entao bloqueia a requisição entre origens por política de segurança.
Solucao:
- Entre no console do S3 e selecione seu Bucket.
- Clique em “Permissions” -> “CORS configuration”.
- Cole está configuração:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Em produção, não use "*". Troque pelo seu domínio real, por exemplo ["https://yourapp.com"].
Na Qiniu Cloud, CORS também precisa ser configurado. Nas configuracoes do Bucket ha uma entrada de “CORS”; o processo e parecido.
Problema 2: URL preassinada expirada - 403 Forbidden
Sintoma: o upload retorna erro 403, com mensagens como “Access Denied” ou “Request has expired”.
Causas comuns:
- A URL expirou, porque passaram mais de 60 segundos ou do tempo definido.
- O horario do servidor está dessincronizado, invalidando a assinatura.
- As permissões IAM sao insuficientes e não permitem upload.
Solucao:
- Problema de horario: confira se o horario do servidor está correto. Use o comando
datee compare com um horario de referencia. Se a diferença passar de 15 minutos, a assinatura pode falhar. - Aumentar validade: mude
expiresInpara 300, ou 5 minutos, dando mais tempo ao usuário. - Conferir permissões: confirme que o papel IAM inclui
s3:PutObjecte que Resource está configurado corretamente.
Eu ja peguei um caso estranho: localmente funcionava, mas depois do deploy na Vercel retornava 403. A causa era que funcoes serverless da Vercel podem iniciar novas instancias a cada chamada, com horario nem sempre alinhado. Resolvi aumentando a validade.
Problema 3: upload grande com timeout ou travamento
Sintoma: a barra de progresso para em 50% ou o upload termina em timeout.
Causas:
- Rede instável, com conexão interrompida.
- Arquivo grande demais, por exemplo video de 200 MB, deixando upload único vulneravel.
- Timeout do navegador ou da Vercel.
Solucao:
-
Arquivos pequenos (<100 MB): implemente repeticao no 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))); } } } -
Arquivos grandes (>100 MB): use Multipart Upload.
- Divida o arquivo em partes de 5 MB.
- Envie parte por parte, repetindo separadamente as que falharem.
- Quando todas terminarem, junte em um arquivo completo.
AWS e Qiniu Cloud oferecem APIs de upload em partes. A configuração so e um pouco mais trabalhosa, porque você precisa gerenciar ETag e número de cada parte.
Problema 4: upload conclui, mas o arquivo não abre - 403 ou 404
Sintoma: o upload retorna 200, mas a URL da imagem da erro ao abrir.
Causas:
- O Bucket está privado e não tem permissão publica de leitura.
- A URL do arquivo foi montada errado.
- O domínio de CDN não está configurado ou ainda não propagou.
Solucao:
-
Acesso público no S3: entre nas configuracoes do Bucket, desative “Block all public access” e adicione isto na Bucket Policy:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::your-bucket-name/*" } ] } -
Acesso público na Qiniu Cloud: nas configuracoes do Bucket, escolha “espaco público”; depois de vincular o domínio CDN, os arquivos passam a ser acessiveis.
-
Validar URL: depois do upload, imprima fileUrl e abra diretamente no navegador. Se der 404, confira nome do Bucket, Region e key do arquivo.
Minha primeira experiência com S3 caiu exatamente nisso: esqueci de ajustar a Bucket Policy, todas as imagens enviadas ficaram inacessiveis, e so descobri depois de uma semana de reclamacoes de usuários.
Resumo
Depois de tudo isso, a ideia central cabe em uma frase: não deixe o arquivo passar pelo seu servidor; envie direto para o armazenamento em nuvem.
A solucao com URL preassinada ou Token de upload contorna o limite de 4 MB do Next.js, aceita arquivos grandes com folga, zera a pressão no servidor e melhora a experiência do usuário. S3 e Qiniu Cloud tem vantagens diferentes: para produto internacional, S3; para mercado chinês, Qiniu Cloud. A escolha depende do seu cenário.
A implementação tecnica não e complicada; o ponto está nos detalhes:
- Seguranca: não vaze chaves, use permissões mínimas no IAM e valide tipos de arquivo
- Desempenho: compressão no cliente e obrigatória e pode economizar 80% de armazenamento e tráfego
- Experiencia: barra de progresso, mensagens claras de erro e fila de upload afetam diretamente a percepcao do produto
- Monitoramento: registre logs e configure alertas para localizar problemas rapidamente
Usei essa solucao em tres projetos de produção, rodando de forma estavel por mais de um ano e processando milhões de uploads. As armadilhas que encontrei estão basicamente na secao de problemas comuns; seguindo essas configuracoes, a chance de pisar nelas cai bastante.
Os códigos do artigo sao completos e executáveis. Se encontrar problema, confira primeiro CORS e permissões IAM: 90% dos erros costumam vir desses dois pontos.
Depois disso, você pode tentar:
- implementar upload por arrastar e soltar, com react-dropzone
- adicionar retomada de upload, com Multipart Upload
- aceitar upload e transcodificação de video, com S3 + AWS MediaConvert
- criar um componente de upload com uma barra de progresso mais caprichada
Upload de arquivo parece simples, mas fazer bem da trabalho. Espero que este artigo economize algumas voltas e ajude você a montar rapidamente um sistema de upload pronto para produção.
Fluxo completo para implementar upload de arquivos no Next.js com S3 ou Qiniu Cloud
Implemente do zero, no Next.js App Router, upload direto de arquivos com URL preassinada, com suporte a S3 e Qiniu Cloud.
⏱️ Estimated time: 45 min
- 1
Step 1: Preparar ambiente e instalar dependências
**Solucao com S3**:
- Instale o AWS SDK: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
- Configure as variáveis de ambiente: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME
- Crie um S3 Bucket e configure regras de CORS, permitindo PUT/POST
- Defina permissões mínimas no IAM, permitindo apenas s3:PutObject e s3:PutObjectAcl
**Solucao com Qiniu Cloud**:
- Instale o SDK da Qiniu: npm install qiniu
- Configure as variáveis de ambiente: QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
- Crie um Bucket e vincule um domínio de CDN
- Configure CORS, se houver acesso entre origens - 2
Step 2: Implementar a API no servidor
**Geração de URL preassinada do S3** (app/api/upload/route.ts):
- Use S3Client e PutObjectCommand
- Valide o arquivo: confira tipo por lista permitida e tamanho máximo, por exemplo 10 MB
- Gere um nome único: uploads/timestamp-nome-original
- Chame getSignedUrl para gerar uma URL temporária de upload válida por 60 segundos
- Retorne uploadUrl, o endereco temporario de upload, e fileUrl, o endereco final de acesso
**Geração de token da Qiniu Cloud** (app/api/qiniu-upload/route.ts):
- Use PutPolicy do SDK qiniu
- Defina scope no formato Bucket:key
- Configure returnBody para definir os dados retornados após o upload
- Gere um uploadToken valido por 3600 segundos
- Retorne token, key e domain - 3
Step 3: Implementar o componente de upload no cliente
**Seleção de arquivo e gerenciamento de estado**:
- Use useState para gerenciar file, uploading, progress e fileUrl
- Use input type="file" para receber o arquivo escolhido pelo usuário
**Fluxo de upload para S3**:
1. Solicite a URL preassinada a API do servidor
2. Use XMLHttpRequest, não fetch, para enviar o arquivo ao S3
3. Escute o evento progress em xhr.upload para atualizar a barra de progresso
4. Use uma requisição PUT e defina Content-Type como o tipo do arquivo
**Fluxo de upload para Qiniu Cloud**:
1. Solicite o uploadToken a API do servidor
2. Monte um FormData com os tres campos file, token e key
3. Envie uma requisição POST para https://upload.qiniup.com
4. Leia a key retornada e monte a URL final de acesso - 4
Step 4: Otimizar compressão de imagens
**Pre-compressão no cliente** (recomendado):
- Instale a biblioteca browser-image-compression
- Configure as opções: maxSizeMB como 1 e maxWidthOrHeight como 1920
- Use Web Worker para não travar a thread principal
- Prefira saída em WebP para reduzir o tamanho
- Envie somente depois da compressão, economizando 80% de armazenamento e tráfego
**Processamento no servidor** (opcional):
- S3: configure um gatilho Lambda para gerar miniaturas automaticamente
- Qiniu Cloud: use parametros de URL, fop, para processar imagens em tempo real
- Exemplo: ?imageView2/2/w/300 gera uma miniatura com 300 px de largura - 5
Step 5: Reforçar a segurança em produção
**Seguranca das chaves**:
- Guarde chaves apenas no .env.local do servidor e nunca faca commit no Git
- Restrinja a política IAM para permitir upload apenas no diretório uploads/*
- Nunca exponha a Secret Key no frontend
**Validacao de arquivos**:
- Valide no servidor tipos permitidos, como image/jpeg e image/png
- Limite o tamanho do arquivo, por exemplo 10 MB
- Opcionalmente, integre uma API de antivirus, como VirusTotal
**Controle de custos**:
- Configure política de ciclo de vida do Bucket para apagar automaticamente arquivos temporários com mais de 30 dias
- Monitore o tamanho armazenado e configure alertas no CloudWatch - 6
Step 6: Melhorar experiência do usuário e tratamento de erros
**Experiencia de upload**:
- Mostre a porcentagem de progresso em tempo real
- Desative o botao durante o upload para evitar cliques repetidos
- Em upload de vários arquivos, limite a concorrencia a 3
- Para arquivos grandes, use retomada por partes com Multipart Upload
**Tratamento de erros**:
- Erro de CORS: confira a configuração de CORS do Bucket
- 403 Forbidden: confira se a URL expirou, as permissões IAM e o horario do servidor
- Timeout de upload: implemente tentativas, no máximo 3, ou upload multipart
- Arquivo inacessivel: confira permissão publica de leitura do Bucket e montagem da URL
**Monitoramento e logs**:
- Registre logs de upload no servidor, incluindo ID do usuário, nome e tamanho do arquivo
- Use Sentry no frontend para capturar erros de upload
- Configure CloudWatch na AWS para monitorar taxa de falhas
FAQ
Por que usar URL preassinada em vez de enviar o arquivo diretamente pelo servidor?
- Remove o limite: API Routes do Next.js costumam ter limite padrão de 4 MB no corpo da requisição, enquanto a URL preassinada aceita uploads únicos de até 5 GB
- Zera a pressão no servidor: o arquivo vai direto para o armazenamento em nuvem, sem consumir memória e CPU do seu servidor, com concorrencia praticamente ilimitada
- E mais rápido: o arquivo evita uma volta pelo servidor, encurta o caminho e pode acelerar o upload em 2 a 3 vezes
No modelo tradicional, o arquivo primeiro sobe para o servidor, consumindo memória, e depois e repassado ao armazenamento em nuvem, consumindo memória de novo. Isso dobra o tráfego e pode derrubar o servidor em horario de pico.
Devo escolher S3 ou Qiniu Cloud? Qual e a principal diferença?
**Escolha S3**: produto internacional, orçamento suficiente, necessidade de integração profunda com o ecossistema AWS, como Lambda e RDS, e foco em estabilidade.
**Escolha Qiniu Cloud**: usuários principalmente na China, equipe pequena com orçamento limitado, necessidade de suporte em chinês e forte demanda por aceleracao via CDN.
Principais diferencas:
- Preço: Qiniu Cloud oferece 10 GB grátis e costuma sair cerca de 40% mais barato; S3 cobra sob demanda e não tem a mesma franquia gratuita
- Velocidade: na China, Qiniu Cloud pode ser 3 a 4 vezes mais rápido, por exemplo 30 ms contra 120 ms; fora da China, S3 tende a ser melhor
- Ecossistema: S3 tem ecossistema maduro; Qiniu Cloud tem comunidade menor
- Processamento de imagens: Qiniu Cloud resolve com parametros de URL; S3 exige Lambda ou servicos de terceiros
O que fazer quando o upload retorna erro de CORS?
**Solucao no S3**:
1. Entre no console do S3 -> Bucket -> Permissions -> CORS configuration
2. Adicione AllowedMethods: ["PUT", "POST"] e AllowedOrigins: ["seu domínio"]
3. Em produção, não use o curinga "*"; informe domínios especificos
**Solucao na Qiniu Cloud**:
1. Entre nas configuracoes do Bucket -> CORS
2. Adicione os domínios e métodos permitidos
3. Garanta que ExposeHeaders inclua ETag
Depois de configurar, aguarde cerca de 5 minutos, limpe o cache do navegador e teste novamente.
Por que usar XMLHttpRequest, e não fetch, para enviar arquivos?
Vantagens do XMLHttpRequest:
- Permite usar xhr.upload.addEventListener('progress') para acompanhar o progresso
- Permite ler e.loaded e e.total para calcular a porcentagem
- A API e antiga, mas ainda e a melhor escolha para esse cenário de upload
Se você não precisa de barra de progresso, fetch também funciona, mas a experiência do usuário piora bastante: ele não sabe em que ponto o upload está.
Comprimir imagem no cliente afeta a qualidade visual?
Dados de teste:
- Uma foto de 5 MB tirada no iPhone caiu para 500 KB, reducao de 90%
- Configuracao com maxSizeMB: 1 e quality: 0.8
- Em telas de celular e computador, não houve diferença visual obvia
Benefícios da compressão:
- Upload 5 vezes mais rápido, comparando 1 MB com 5 MB
- 80% menos custo de armazenamento
- Menos custo de tráfego no CDN
- Acesso mais fluido no celular
Se a qualidade for critica, como em um portfolio de fotografia, aumente quality para 0.9 ou pule a compressão.
O upload concluiu, mas o arquivo não abre. Qual pode ser o motivo?
**Permissao do Bucket** (o mais frequente):
- S3: a Bucket Policy não tem permissão s3:GetObject ou "Block all public access" está ativado
- Qiniu Cloud: o Bucket e privado e precisa ser alterado para público
**URL montada errado**:
- Confira se o formato de fileUrl está correto
- S3: https://bucket-name.s3.region.amazonaws.com/key
- Qiniu Cloud: https://cdn-domain/key
**CDN ainda não propagou**:
- Depois de vincular um domínio de CDN na Qiniu Cloud, pode levar 5 a 10 minutos para funcionar
- Durante testes, use primeiro o domínio de teste da Qiniu
Para diagnosticar, abra fileUrl diretamente no navegador e veja o erro concreto: 403 de permissão ou 404 de caminho.
Como implementar upload de arquivos grandes, acima de 100 MB?
Ideia de implementação:
1. Divida o arquivo em partes de 5 MB com Blob.slice
2. Envie cada parte e registre o ETag de cada uma
3. Repita separadamente apenas as partes que falharem
4. Depois que todas as partes subirem, chame CompleteMultipartUpload para juntar tudo
**APIs de multipart no S3**:
- CreateMultipartUpload: cria a tarefa e retorna UploadId
- UploadPart: envia cada parte
- CompleteMultipartUpload: junta todas as partes
**Upload em partes na Qiniu Cloud**:
- Use mkblk para criar blocos
- Use bput para enviar partes
- Use mkfile para juntar tudo
A implementação completa e mais complexa; vale seguir o tutorial de Multipart Upload da documentação oficial da AWS.
1 min de leitura · Publicado em: 7 jan 2026 · Atualizado em: 14 jul 2026
Guia completo Next.js
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
E-commerce com Next.js: carrinho e pagamentos com Stripe do início ao fim
Implemente um carrinho com Zustand e pagamentos com Stripe no Next.js, incluindo Checkout Session, Webhook, criação de pedidos, controle de estoque e implantação.
Parte 37 de 51
Próximo
Admin em Next.js na prática: guia completo de RBAC, do design à implementação
Guia completo para implementar RBAC em um painel administrativo com Next.js 15, cobrindo proteção de rotas por middleware, menus dinâmicos, tabelas com shadcn/ui e boas práticas de segurança.
Parte 39 de 51




Comentários
Entre com GitHub para comentar