Destino Dev

Blog

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):

  1. app/actions/uploadActions.ts — Server Actions com AWS SDK

  2. app/api/upload/route.ts — endpoint delegando às Server Actions

  3. 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:

  1. Balanceie tamanho do chunk: 8-16MB é um bom ponto de partida.

  2. Paralelize o upload de partes (por exemplo 3-5 requisições em paralelo) para acelerar, mas respeite limites de rede do cliente.

  3. Monitore ETag e valide integridade se necessário (checksums).

  4. 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