Uploads grandes no Next.js App Router: Server Actions + upload resumível para S3
Por John Pires · 31 de março de 2026

Uploads grandes no Next.js App Router: Server Actions + upload resumível para S3
O que é e por que importa
O problema dos uploads grandes
Enviar arquivos grandes (vídeos, backups, datasets) pela web traz problemas práticos: timeouts, conexões instáveis, consumo de memória no servidor e usuários que têm que recomeçar o envio do zero quando a rede cai. Em aplicações modernas esses uploads precisam ser resumíveis (resumable), robustos e pouco custosos para o servidor.
Next.js no App Router muda a forma como pensamos a separação cliente/servidor: agora podemos usar Server Actions para gerar credenciais e URLs assinadas no servidor, mantendo chaves e permissões fora do cliente.
Por que Next.js App Router + Server Actions ajuda
Server Actions permitem executar lógica segura no servidor sem criar handlers REST explícitos para tudo. Para uploads grandes, a placa-mãe do fluxo é: o cliente solicita ao servidor os metadados e presigned URLs para cada parte; o cliente envia partes diretamente ao S3; o cliente finaliza o upload chamando o servidor para consolidar o multipart upload. Isso minimiza uso de CPU/RAM no servidor.
O objetivo deste artigo é mostrar um fluxo completo: gerar um multipart upload no servidor com Server Actions, presign de cada parte, upload por partes no cliente com resume via localStorage e completar o upload.
Conceitos fundamentais com analogias
Multipart upload e resumibilidade (analogias)
Imagine enviar um conjunto de caixas num caminhão: não precisa esperar o caminhão inteiro encher para partir. Você coloca caixas (partes), e cada caixa tem um recibo (ETag). Quando todas as caixas chegam, o gerente (S3) junta tudo no destino final. Se uma caixa se perde, você só reenvia a caixa perdida. Multipart upload funciona exatamente assim.
Nas redes, dividir um arquivo em partes permite recomeçar só a parte que falhou, reduzindo retrabalho e tornando o upload *resumível*.
Server Actions e fluxo cliente-servidor
Server Actions atuam como o gerente que inicia o processo: cria o upload, emite recibos (presigned URLs) e confirma a união final. O cliente é quem empacota as caixas e as envia diretamente para S3. O servidor não retransmite bytes, apenas autoriza e finaliza.
Usaremos um modelo híbrido: Server Actions (lógica segura com chaves AWS) e um endpoint API simples que delega para as mesmas ações para facilitar chamadas por fetch no cliente. Isso é robusto e compatível com o App Router.
Passo a passo com código comentado
1) Setup do S3 e permissões
Antes de codificar, crie um bucket S3 e uma policy mínima para permitir multipart uploads: CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload e GetObject. Configure as variáveis de ambiente no seu projeto Next.js: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY e S3_BUCKET.
Checklist inicial:
Bucket criado
Credenciais com permissão limitada
Variáveis de ambiente definidas no Vercel/host
2) Server Actions: criar multipart, presign e completar
Abaixo está o arquivo de ações do servidor. Ele usa AWS SDK v3 para criar o multipart upload e gerar URLs assinadas para cada parte.
O código retorna { uploadId, key, partUrls } — o cliente faz PUTs diretamente nesses URLs e captura o ETag de cada resposta.
/* app/actions/uploadActions.ts (servidor) */
import { S3Client, CreateMultipartUploadCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, UploadPartCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const REGION = process.env.AWS_REGION;
const BUCKET = process.env.S3_BUCKET;
const s3 = new S3Client({ region: REGION });
export async function createMultipart(key: string, contentType: string, parts: number) {
'use server'; // Server Action directive
// Cria o upload multipart no S3
const createCmd = new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: key, ContentType: contentType });
const createRes = await s3.send(createCmd);
const uploadId = createRes.UploadId;
if (!uploadId) throw new Error('failed to create multipart upload');
// Gera presigned URL para cada part
const urls = await Promise.all(
Array.from({ length: parts }, (_, i) => i + 1).map(async (partNumber) => {
const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, PartNumber: partNumber, UploadId: uploadId });
const url = await getSignedUrl(s3, cmd, { expiresIn: 3600 });
return { partNumber, url };
})
);
return { uploadId, key, urls };
}
export async function completeMultipart(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
'use server';
// Ordena partes por PartNumber
parts.sort((a, b) => a.PartNumber - b.PartNumber);
const command = new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map((p) => ({ ETag: p.ETag, PartNumber: p.PartNumber })),
},
});
const res = await s3.send(command);
return res;
}
export async function abortMultipart(key: string, uploadId: string) {
'use server';
const cmd = new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId });
const res = await s3.send(cmd);
return res;
}
O que esse bloco faz: cria upload multipart no S3, gera URLs assinadas de PUT para cada parte e oferece funções para completar/abortar.
O que acontece depois: o cliente usará os URLs gerados para enviar partes diretamente ao S3. O servidor só valida e finaliza.
3) Endpoint API que delega às Server Actions
Para facilitar chamadas via fetch do cliente, exponha um handler API que reutiliza essas funções. O handler fica em app/api/upload/route.ts.
/* app/api/upload/route.ts */
import { NextResponse } from 'next/server';
import { createMultipart, completeMultipart, abortMultipart } from '../../actions/uploadActions';
export async function POST(req: Request) {
const url = new URL(req.url);
const action = url.searchParams.get('action');
const body = await req.json();
try {
if (action === 'start') {
const { key, contentType, parts } = body;
const res = await createMultipart(key, contentType, parts);
return NextResponse.json(res);
}
if (action === 'complete') {
const { key, uploadId, parts } = body;
const res = await completeMultipart(key, uploadId, parts);
return NextResponse.json(res);
}
if (action === 'abort') {
const { key, uploadId } = body;
const res = await abortMultipart(key, uploadId);
return NextResponse.json(res);
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
O que esse bloco faz: publica um endpoint único /api/upload que aceita ações start/complete/abort. Ele chama as Server Actions implementadas no arquivo anterior.
O que acontece depois: o cliente usa fetch('/api/upload?action=start', ...) para iniciar o fluxo.
4) Cliente: uploader em blocos com resume
Agora o cliente. A ideia: dividir o arquivo em blocos, pedir presigned URLs para cada bloco, enviar cada bloco com PUT e armazenar no localStorage o estado (uploadId, key, partes enviadas). Assim, se a rede cair, o usuário pode retomar.
Regras práticas para iniciantes:
Escolha um tamanho de chunk entre 5MB e 50MB (S3 exige >=5MB, exceto o último part).
Capture ETag da resposta do PUT para cada part.
Armazene metadata no localStorage: { key, uploadId, parts: [{partNumber, ETag}] }.
/* app/upload/page.tsx (componente cliente) */
'use client';
import React, { useState } from 'react';
const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB
export default function UploadPage() {
const [progress, setProgress] = useState(0);
async function startUpload(file: File) {
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const key = `uploads/${Date.now()}-${file.name}`;
// Inicia no servidor para gerar presigned URLs
const startRes = await fetch(`/api/upload?action=start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, contentType: file.type || 'application/octet-stream', parts: totalParts }),
});
const startData = await startRes.json();
const { uploadId, urls } = startData;
// Armazena estado para resumir
const stateKey = `upload:${key}`;
const saved = { key, uploadId, parts: [] as any[] };
localStorage.setItem(stateKey, JSON.stringify(saved));
const parts: { ETag: string; PartNumber: number }[] = [];
for (let index = 0; index < totalParts; index++) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const { url } = urls[index];
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: blob,
});
if (!res.ok) {
// salva progresso parcial e para
console.error('upload part failed', await res.text());
break;
}
const etag = res.headers.get('ETag') || '';
parts.push({ ETag: etag.replace(/\"/g, ''), PartNumber: index + 1 });
// atualiza progresso e state
const savedState = JSON.parse(localStorage.getItem(stateKey) || '{}');
savedState.parts = parts;
localStorage.setItem(stateKey, JSON.stringify(savedState));
setProgress(Math.round(((index + 1) / totalParts) * 100));
}
// Finaliza no servidor
await fetch(`/api/upload?action=complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, uploadId, parts }),
});
// Limpa estado
localStorage.removeItem(stateKey);
setProgress(100);
}
function handleFile(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
startUpload(file).catch((err) => console.error(err));
}
return (
Enviar arquivo grande (resumível)
Progresso: {progress}%
);
}
O que esse bloco faz: lê o arquivo, solicita URLs ao servidor, faz PUTs em cada URL e captura o ETag. O cliente guarda estado em localStorage para retomar se necessário.
O que acontece depois: com todas as partes enviadas, chamamos action complete para combinar as partes no S3.
Exemplo prático completo
Estrutura do projeto
Arquivos-chave no App Router (exemplo minimal):
app/actions/uploadActions.ts — Server Actions com AWS SDK
app/api/upload/route.ts — endpoint delegando às Server Actions
app/upload/page.tsx — componente cliente que faz chunked upload
Essa separação mantém lógica de credenciais no servidor e simplicidade no cliente.
Arquivo completo: page.tsx (detalhes do cliente)
Segue um código de cliente com tratamento simples de resume (busca estado no localStorage antes de iniciar).
/* app/upload/page.tsx (versão com resume detectando estado) */
'use client';
import React, { useState } from 'react';
const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB
export default function UploadPage() {
const [progress, setProgress] = useState(0);
async function resumeOrStart(file: File) {
const key = `uploads/${Date.now()}-${file.name}`;
const stateKey = `upload:${key}`;
const saved = localStorage.getItem(stateKey);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.uploadId && parsed.parts) {
// Retomar lógica: solicitar URLs novamente e enviar partes faltantes
// Para simplicidade, vamos reiniciar as URLs pedindo start ao servidor
}
}
return startUpload(file, key, stateKey);
}
async function startUpload(file: File, key: string, stateKey: string) {
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const startRes = await fetch(`/api/upload?action=start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, contentType: file.type || 'application/octet-stream', parts: totalParts }),
});
const startData = await startRes.json();
const { uploadId, urls } = startData;
const parts: { ETag: string; PartNumber: number }[] = [];
localStorage.setItem(stateKey, JSON.stringify({ key, uploadId, parts }));
for (let index = 0; index < totalParts; index++) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const url = urls[index].url; // assumimos ordem
const res = await fetch(url, { method: 'PUT', body: blob, headers: { 'Content-Type': file.type }});
if (!res.ok) throw new Error('part upload failed');
const etag = (res.headers.get('ETag') || '').replace(/\"/g, '');
parts.push({ ETag: etag, PartNumber: index + 1 });
localStorage.setItem(stateKey, JSON.stringify({ key, uploadId, parts }));
setProgress(Math.round(((index + 1) / totalParts) * 100));
}
await fetch(`/api/upload?action=complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, uploadId, parts }) });
localStorage.removeItem(stateKey);
setProgress(100);
}
function handleFile(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
resumeOrStart(file).catch(err => console.error(err));
}
return (
Envio resumível
Progresso: {progress}%
Dica: mantenha o estado do upload no localStorage com uploadId e ETags para retomar.
);
}
O que esse arquivo faz: gerencia upload chunked, salva estado e finaliza com a API. Ele é suficiente para começar a enviar arquivos grandes de forma confiável.
Erros comuns, armadilhas e dicas práticas
Armadilhas frequentes
Listagem de erros que iniciantes costumam cometer e como evitá-los:
Part size menor que 5MB: S3 exige que todas as partes, exceto a última, tenham pelo menos 5MB. Use chunk >= 5MB.
ETag ausente: nem sempre o header ETag retorna como você espera. Capture e normalize (remova aspas).
Perda de ordem: envie e registre PartNumber corretamente; CompleteMultipartUpload exige partes ordenadas.
Credenciais expostas: gere URLs assinadas no servidor; nunca exponha chaves AWS ao cliente.
Dicas de otimização
Melhore confiabilidade e custo:
Balanceie tamanho do chunk: 8-16MB é um bom ponto de partida.
Paralelize o upload de partes (por exemplo 3-5 requisições em paralelo) para acelerar, mas respeite limites de rede do cliente.
Monitore ETag e valide integridade se necessário (checksums).
Configure lifecycle do bucket para limpar uploads incompletos (AbortMultipartUpload após X dias).
Tip prático: implemente um retry exponencial no cliente para cada part. Pequenos erros de rede são esperados.
Próximos passos para aprofundar
Monitoramento e reprocessamento
Ao passar do protótipo para produção, agregue logs e métricas: quantos uploads iniciaram, quantos foram completos, taxa de falha por parte. Use SNS/SQS para pós-processamento quando um upload for completado (ex: gerar thumbnails).
Considere usar CloudWatch ou um serviço de observabilidade para rastrear long-lived uploads e detectar abortos.
Alternativas e complementos
Ferramentas/abordagens para avaliar:
tus.io — protocolo resumível com bibliotecas client/server prontas.
S3 Transfer Acceleration — para uploads globais mais rápidos.
CDN + signed cookies — para entrega de conteúdo depois do upload.
Conclusão prática
Você viu um fluxo completo para uploads grandes usando Next.js App Router: Server Actions (lógica segura) + presigned URLs S3 para upload multipart resumível. O servidor cria e finaliza uploads; o cliente envia partes diretamente ao S3 e salva progresso localmente para retomar em caso de falhas.
Principais passos práticos:
Crie multipart upload no servidor e gere URLs assinadas por parte.
Divida arquivo em chunks >= 5MB e envie cada parte diretamente ao S3.
Capture ETag por parte, armazene estado (localStorage) e finalize com CompleteMultipartUpload.
Implementação mínima está pronta para testes. A partir daqui, adicione paralelismo, retries, validação e monitoramento para transformar o protótipo em produção.
Para finalizar: mantenha credenciais no servidor, prefira Server Actions para lógica sensível no App Router e use presigned URLs para que o servidor nunca carregue bytes do arquivo inteiro. Isso reduz custo, aumenta robustez e proporciona uma boa experiência para o usuário final.
Assuntos: Next.js, server-actions, s3, multipart-upload, resumable
Leia também
WebAuthn sem senha no Next.js (App Router) + Supabase: passo a passo
Guia prático e passo a passo para implementar WebAuthn sem senha em Next.js (App Router) com Supabase: conceitos, endpoints server, componente cliente e dicas…
A/B testing no Next.js (App Router): middleware, cookies e ISR passo a passo
Guia prático para implementar A/B testing no Next.js App Router: use middleware para atribuir variantes, cookies para persistência e rewriting para habilitar…
Agrupamento de requisições no App Router com Server Actions e Edge Cache
Padrões práticos para agrupar requisições no App Router usando Server Actions e Edge Cache: batch endpoints, ISR (revalidate), retries, deduplicação e…
Formulários sem JavaScript com Server Actions no Next.js (App Router)
Aprenda a criar formulários totalmente funcionais sem JavaScript usando Server Actions no Next.js (App Router). Exemplo prático com código, validação…
SSR multi-região no Next.js App Router: sincronizar ISR e invalidar caches
Guia prático para sincronizar ISR em implantações multi-região com Next.js App Router: padrões, Server Actions, endpoint de revalidate, broadcast entre…
Preview CMS com Next.js App Router: Server Actions, Middleware e ISR na prática
Implementar preview CMS no Next.js App Router exige cookies seguros, middleware para marcar requisições e uso combinado de ISR e Server Actions. Neste artigo…