Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Upload de arquivo no Express com multer: imagem e limite

Receber multipart/form-data, salvar em disco com nome seguro, limitar tamanho e tipo e devolver a URL do arquivo — testado com curl e com formulário.

Rodolfo Mori11 min de leitura

Upload no Express é um middleware a mais na rota: o multer lê o corpo em multipart/form-data, grava os bytes onde você mandar e coloca os metadados do arquivo em req.file. O resto — nome seguro, limite de tamanho, tipo aceito — é decisão sua, e é onde os problemas moram.

Todos os exemplos aqui são da secretaria da Escola Vila Nova: a foto do aluno para a carteirinha e os documentos da matrícula. A API é a mesma que você subiu em Express do zero, agora recebendo arquivo.

Formulário comum é carta; upload é encomenda

Um corpo JSON se parece com uma carta: texto organizado que o destinatário lê de uma vez. Um upload leva texto e bytes de arquivo, como uma encomenda com documento, etiqueta e objeto dentro. O formato HTTP criado para separar essas partes é o multipart/form-data. Cada parte tem seus próprios headers e seu próprio conteúdo.

Por isso express.json() não resolve: ele foi feito para interpretar JSON, não para receber um fluxo de bytes e descobrir onde cada arquivo termina. O multer faz o trabalho da área de recebimento e coloca a etiqueta em req.file. Antes de instalar qualquer coisa, abra o DevTools de um formulário com arquivo e observe o Content-Type e o boundary. Ver a divisória real transforma “multer é obrigatório” em uma explicação de protocolo, não numa receita cega.

multipart/form-data: por que o express.json não dá conta

Um formulário que manda arquivo precisa de um atributo a mais:

html
<form action="/alunos/foto" method="post" enctype="multipart/form-data">
  <label for="matricula">Matrícula</label>
  <input id="matricula" name="matricula" value="2026-0413" />

  <label for="foto">Foto 3x4</label>
  <input id="foto" name="foto" type="file" accept="image/png, image/jpeg" />

  <button>Enviar</button>
</form>

Sem enctype="multipart/form-data", o navegador manda o formulário no formato padrão, application/x-www-form-urlencoded — e nesse formato não cabe arquivo. Ele envia só o nome:

js
import express from 'express';

const app = express();
app.use(express.urlencoded({ extended: true }));

app.post('/alunos/foto', (req, res) => {
  console.log('content-type:', req.headers['content-type']);
  console.log('req.body:', req.body);
  res.json(req.body);
});

app.listen(5050);
content-type: application/x-www-form-urlencoded req.body: { matricula: '2026-0413', foto: 'foto-ana.png' }

O foto ali é uma string com o nome do arquivo. Os bytes da imagem nunca saíram do computador do usuário.

Com o enctype certo, o corpo muda de forma. Ele vira uma sequência de partes separadas por uma linha de fronteira sorteada — o boundary. Este servidor não faz nada além de imprimir os primeiros bytes que chegam:

js
import http from 'node:http';

http
  .createServer((req, res) => {
    const pedacos = [];
    req.on('data', (pedaco) => pedacos.push(pedaco));
    req.on('end', () => {
      const bruto = Buffer.concat(pedacos);
      console.log('bytes recebidos:', bruto.length);
      // sem os \r, só para caber na tela
      console.log(bruto.subarray(0, 260).toString('latin1').replace(/\r/g, ''));
      res.end('ok\n');
    });
  })
  .listen(5050);

Enviando pelo curl:

bash
curl -F "matricula=2026-0413" -F "foto=@foto-ana.png" http://localhost:5050/alunos/foto
bytes recebidos: 1228 --------------------------hRgKX2h4ZeB9hLYceVJaH2 Content-Disposition: form-data; name="matricula"

2026-0413 –––––––––––––hRgKX2h4ZeB9hLYceVJaH2 Content-Disposition: form-data; name=“foto”; filename=“foto-ana.png” Content-Type: image/png

Cada campo vira um bloco com cabeçalho próprio, e o do arquivo carrega filename e Content-Type. Depois desse cabeçalho vêm os bytes crus da imagem — por isso o express.json() passa longe: ele só sabe ler JSON, e sequer tenta tocar num corpo com esse content-type.

js
import express from 'express';

const app = express();
app.use(express.json());

app.post('/alunos/foto', (req, res) => {
  console.log('content-type:', req.headers['content-type']);
  console.log('req.body:', req.body);
  res.json({ recebido: req.body ?? null });
});

app.listen(5050, () => console.log('Secretaria on-line na porta 5050'));
Secretaria on-line na porta 5050 content-type: multipart/form-data; boundary=------------------------BU3uBBEWESq2g0tqsZ9xQ0 req.body: undefined

undefined, não {}. No Express 5, quando nenhum parser reconhece o corpo, req.body nem chega a existir — o mesmo comportamento que aparece quando você esquece o express.json(), explicado em req.params, req.query e req.body.

multer: de bytes soltos a req.file

O multer é o parser que falta. Instale e dê a ele uma pasta de destino:

bash
npm install express multer
js
import express from 'express';
import multer from 'multer';

const upload = multer({ dest: 'uploads/' });
const app = express();

app.post('/alunos/foto', upload.single('foto'), (req, res) => {
  console.log(req.file);
  console.log('req.body:', req.body);
  res.status(201).json({ ok: true, tamanho: req.file.size });
});

app.listen(5050, () => console.log('Secretaria on-line na porta 5050'));

upload.single('foto') é um middleware como qualquer outro: roda antes do seu handler e prepara req. O 'foto' é o name do input — se não bater, o multer recusa o arquivo.

{ fieldname: 'foto', originalname: 'foto-ana.png', encoding: '7bit', mimetype: 'image/png', path: 'uploads/290886ea08339075a5ead7113017259c', destination: 'uploads/', filename: '290886ea08339075a5ead7113017259c', size: 914 } req.body: [Object: null prototype] { matricula: '2026-0413' }

Três coisas para reparar. O arquivo já está no disco quando o seu código roda — o multer grava primeiro e chama o handler depois. O nome salvo é um hash aleatório sem extensão, então uploads/290886… não abre com dois cliques. E req.body só existe depois do multer: dentro dele estão os campos de texto do mesmo formulário.

diskStorage: você escolhe a pasta e o nome

O dest: é o atalho. Para decidir o nome do arquivo, troque por um diskStorage, que recebe duas funções:

js
const storage = multer.diskStorage({
  destination: 'uploads/',
  filename: (req, file, cb) => cb(null, file.originalname),
});

const upload = multer({ storage });

app.post('/alunos/foto', upload.single('foto'), (req, res) => {
  res.status(201).json({ arquivo: req.file.filename, bytes: req.file.size });
});

Parece a escolha óbvia: guardar com o nome original. É também a mais perigosa.

O upload que apaga a foto do vizinho

Ana e Bruno mandam a foto da carteirinha. Os dois arquivos se chamam foto.png, porque foi assim que a câmera do celular salvou:

bash
curl -s -F "foto=@foto-ana.png;filename=foto.png" http://localhost:5050/alunos/foto
curl -s -F "foto=@foto-bruno.png;filename=foto.png" http://localhost:5050/alunos/foto
ls -l uploads
{"arquivo":"foto.png","bytes":914} {"arquivo":"foto.png","bytes":267068} total 528 -rw-r--r--@ 1 rodolfomori wheel 267068 Aug 22 19:58 foto.png

Dois uploads, um arquivo. A foto da Ana virou a foto do Bruno, sem erro nenhum no console — e no banco de dados as duas matrículas apontam para o mesmo foto.png. Esse bug só aparece quando alguém reclama que a carteirinha saiu com a cara errada.

A correção é sortear o nome e aproveitar só a extensão do original:

js
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import multer from 'multer';

const storage = multer.diskStorage({
  destination: 'uploads/fotos',
  filename: (req, file, cb) => {
    const extensao = path.extname(file.originalname).toLowerCase();
    cb(null, `${randomUUID()}${extensao}`);
  },
});

app.post('/alunos/foto', multer({ storage }).single('foto'), (req, res) => {
  res.status(201).json({
    matricula: req.body.matricula,
    url: `/fotos/${req.file.filename}`,
  });
});

Os mesmos dois envios, agora com a matrícula junto, e um ls -l uploads/fotos no fim:

{"matricula":"2026-0413","url":"/fotos/8adbeea3-c963-4d0b-8ed5-888e767056a1.png"} {"matricula":"2026-0517","url":"/fotos/3540c535-f566-42fc-b44c-0111a5fe4789.png"} total 536 -rw-r--r--@ 1 rodolfomori wheel 267068 Aug 22 19:58 3540c535-f566-42fc-b44c-0111a5fe4789.png -rw-r--r--@ 1 rodolfomori wheel 914 Aug 22 19:58 8adbeea3-c963-4d0b-8ed5-888e767056a1.png

A pasta uploads/fotos nem existia: quando destination é uma string, o multer cria o caminho sozinho. Quando destination é uma função — caso da última seção, com duas pastas — criar a pasta é com você, e esquecer disso dá o erro clássico do fs:

Error: ENOENT: no such file or directory, open 'uploads/fotos/3efda76d-dad1-4ade-be61-9f981646bd74.png'

Limitar o tamanho e tratar o LIMIT_FILE_SIZE

Sem limite, uma foto de 40 MB entra e ocupa disco. O limite vive em limits, e fileSize é em bytes:

js
const upload = multer({
  storage,
  limits: { fileSize: 200 * 1024, files: 1 },
});

Uma foto de 914 bytes passa; a de 267.068 bytes estoura. Sem tratamento, o erro sobe para o handler padrão do Express e o cliente recebe uma página HTML de erro 500:

bash
curl -s -o /dev/null -w "ana: status %{http_code}\n" -F "foto=@foto-ana.png" http://localhost:5050/alunos/foto
curl -s -o /dev/null -w "bruno: status %{http_code}\n" -F "foto=@foto-bruno.png" http://localhost:5050/alunos/foto
ana: status 201 bruno: status 500 MulterError: File too large at abortWithCode (/private/tmp/escola-api/node_modules/multer/lib/make-middleware.js:107:22) at FileStream.<anonymous> (/private/tmp/escola-api/node_modules/multer/lib/make-middleware.js:221:11) at FileStream.emit (node:events:509:28) at SBMH.ssCb [as _cb] (/private/tmp/escola-api/node_modules/busboy/lib/types/multipart.js:479:32) at feed (/private/tmp/escola-api/node_modules/streamsearch/lib/sbmh.js:248:10) at SBMH.push (/private/tmp/escola-api/node_modules/streamsearch/lib/sbmh.js:104:16) at Multipart._write (/private/tmp/escola-api/node_modules/busboy/lib/types/multipart.js:567:19) at writeOrBuffer (node:internal/streams/writable:570:12) at _write (node:internal/streams/writable:499:10) at Writable.write (node:internal/streams/writable:508:10)

Duas boas notícias escondidas nesse trace. O abortWithCode sai de um evento do próprio stream do arquivo: o multer corta no meio do upload, assim que a contagem passa do limite, em vez de esperar os 267 KB chegarem. E o pedaço já gravado ele apaga sozinho:

bash
ls -l uploads/fotos
total 8 -rw-r--r--@ 1 rodolfomori wheel 914 Aug 22 19:59 070af604-0728-4f14-b95a-dd0f189d05e0.png

Só a foto da Ana sobrou. Nenhum resto do upload interrompido ficou no disco.

500 é a resposta errada, porém: o servidor está bem, quem errou foi o cliente. Um error handler no fim da cadeia traduz o código do multer em status HTTP honesto:

js
app.use((erro, req, res, next) => {
  if (erro instanceof multer.MulterError) {
    console.log('MulterError:', erro.code, '| campo:', erro.field);
    if (erro.code === 'LIMIT_FILE_SIZE') {
      return res.status(413).json({ erro: 'A foto passa de 200 KB. Reduza e reenvie.' });
    }
    return res.status(400).json({ erro: erro.code, campo: erro.field });
  }
  next(erro);
});
{"erro":"A foto passa de 200 KB. Reduza e reenvie."} status 413 {"erro":"LIMIT_UNEXPECTED_FILE","campo":"arquivo"} status 400 MulterError: LIMIT_FILE_SIZE | campo: foto MulterError: LIMIT_UNEXPECTED_FILE | campo: arquivo

O segundo envio usou -F "arquivo=@foto-ana.png" — nome de campo diferente do upload.single('foto'). O multer devolve LIMIT_UNEXPECTED_FILE com o campo que causou o problema, e é assim que você descobre um name errado no HTML sem abrir o DevTools. Repare no return antes de cada res: responder e ainda seguir para o próximo handler é o caminho curto para o erro Cannot set headers after they are sent.

O mimetype mente; os primeiros bytes não

fileFilter decide se o arquivo entra. A tentação é confiar no mimetype:

js
const PERMITIDOS = ['image/png', 'image/jpeg'];

const upload = multer({
  storage,
  limits: { fileSize: 200 * 1024, files: 1 },
  fileFilter: (req, file, cb) => {
    console.log('fileFilter viu:', file.originalname, '->', file.mimetype);
    if (!PERMITIDOS.includes(file.mimetype)) {
      return cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', 'foto'));
    }
    cb(null, true);
  },
});

O problema é que esse mimetype não é medido pelo servidor: ele é copiado do cabeçalho Content-Type que o cliente escreveu. Montei um arquivo com o cabeçalho de executável do DOS/Windows — o file do sistema o classifica como MS-DOS executable —, chamei de boleto-falso.png e mandei duas vezes, mudando só o que declaro:

bash
curl -s -w " | status %{http_code}\n" -F "foto=@boleto-falso.png;type=application/pdf" http://localhost:5050/alunos/foto
curl -s -w " | status %{http_code}\n" -F "foto=@boleto-falso.png;type=image/png" http://localhost:5050/alunos/foto
file uploads/fotos/*
{"erro":"LIMIT_UNEXPECTED_FILE"} | status 400 {"url":"/fotos/635de96a-e1aa-4838-9d89-6ea87f36889b.png","mimetype":"image/png"} | status 201 fileFilter viu: boleto-falso.png -> application/pdf fileFilter viu: boleto-falso.png -> image/png uploads/fotos/635de96a-e1aa-4838-9d89-6ea87f36889b.png: MS-DOS executable, MZ for MS-DOS

Mesmo arquivo, dois destinos. Trocando uma palavra no cabeçalho, um executável entrou na pasta de fotos dos alunos com extensão .png e status 201.

O que não se falsifica de graça é o começo do arquivo. Todo formato tem uma assinatura fixa nos primeiros bytes — o magic number. PNG começa com 89 50 4E 47 0D 0A 1A 0A; JPEG, com FF D8 FF. Depois do upload, abra o arquivo, leia esses bytes e compare:

js
import fs from 'node:fs/promises';

const ASSINATURAS = {
  'image/png': Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  'image/jpeg': Buffer.from([0xff, 0xd8, 0xff]),
};

app.post('/alunos/foto', upload.single('foto'), async (req, res) => {
  const esperada = ASSINATURAS[req.file.mimetype];
  const arquivo = await fs.open(req.file.path, 'r');
  const inicio = Buffer.alloc(esperada.length);
  await arquivo.read(inicio, 0, esperada.length, 0);
  await arquivo.close();
  console.log('primeiros bytes:', inicio.toString('hex'), '| esperado:', esperada.toString('hex'));

  if (!inicio.equals(esperada)) {
    await fs.unlink(req.file.path);
    return res.status(415).json({ erro: 'O arquivo não é uma imagem de verdade.' });
  }

  res.status(201).json({ url: `/fotos/${req.file.filename}` });
});

Mandando o falso e a foto real, nessa ordem:

bash
curl -s -w " | status %{http_code}\n" -F "foto=@boleto-falso.png;type=image/png" http://localhost:5050/alunos/foto
curl -s -w " | status %{http_code}\n" -F "foto=@foto-ana.png;type=image/png" http://localhost:5050/alunos/foto
{"erro":"O arquivo não é uma imagem de verdade."} | status 415 {"url":"/fotos/363b4ec6-0847-4d6b-ba81-111c931f8656.png"} | status 201 primeiros bytes: 4d5a900003000000 | esperado: 89504e470d0a1a0a primeiros bytes: 89504e470d0a1a0a | esperado: 89504e470d0a1a0a

4d5a é MZ em ASCII — o cabeçalho de executável do DOS/Windows. O arquivo falso foi barrado com 415 e removido do disco; a foto real passou. As duas camadas se completam: o fileFilter derruba cedo o que nem se disfarça, e a assinatura derruba o que mentiu. A leitura usa o mesmo fs/promises da lição de arquivos.

Servindo a foto salva com express.static

Gravar não basta: a carteirinha precisa de uma URL. Uma linha resolve, mas repare no caminho — a pasta é montada num prefixo público, e nunca na raiz:

js
app.use('/fotos', express.static('uploads/fotos', { index: false }));
bash
curl -I http://localhost:5050/fotos/2cfb96ca-d4db-446e-9054-8b06d1e05f9b.png
HTTP/1.1 200 OK X-Powered-By: Express Accept-Ranges: bytes Cache-Control: public, max-age=0 Last-Modified: Sat, 22 Aug 2026 23:01:49 GMT ETag: W/"392-1a02bb57b48" Content-Type: image/png Content-Length: 914 Date: Sat, 22 Aug 2026 23:01:49 GMT Connection: keep-alive Keep-Alive: timeout=5

O Content-Type: image/png do cabeçalho saiu da extensão do arquivo, não do conteúdo. É a última razão para a checagem de assinatura: sem ela, o executável disfarçado seria servido pela sua API como se fosse uma imagem legítima. E como o nome é sorteado, ninguém adivinha a URL da foto de outro aluno — se o dado for sensível, ainda assim ponha uma rota autenticada na frente em vez de static.

Até aqui todo envio foi por curl. O formulário lá do começo monta exatamente a mesma requisição, e dá para conferir isso sem abrir o navegador: FormData é a mesma API que o <form> usa por dentro.

js
import { readFile } from 'node:fs/promises';

const dados = new FormData();
dados.append('matricula', '2026-0413');
dados.append(
  'foto',
  new Blob([await readFile('foto-ana.png')], { type: 'image/png' }),
  'foto-ana.png',
);

const resposta = await fetch('http://localhost:5050/alunos/foto', {
  method: 'POST',
  body: dados,
});

console.log(resposta.status, await resposta.json());
201 { matricula: '2026-0413', url: '/fotos/d0518712-bf86-4c9d-a796-d3676a38d925.png' }

Repare no que não está ali: nenhum Content-Type escrito na mão. Quem passa um FormData para o fetch não deve definir esse cabeçalho — o boundary é sorteado na hora, e um Content-Type fixo escrito por você quebraria a leitura do corpo no servidor.

Vários arquivos e campos de texto no mesmo envio

A matrícula manda uma foto e até três documentos. upload.fields nomeia cada campo, e destination como função separa as pastas:

js
const storage = multer.diskStorage({
  destination: (req, file, cb) =>
    cb(null, file.fieldname === 'foto' ? 'uploads/fotos' : 'uploads/documentos'),
  filename: (req, file, cb) =>
    cb(null, `${randomUUID()}${path.extname(file.originalname).toLowerCase()}`),
});

const campos = multer({ storage, limits: { fileSize: 2 * 1024 * 1024, files: 4 } }).fields([
  { name: 'foto', maxCount: 1 },
  { name: 'documentos', maxCount: 3 },
]);

app.post('/matriculas', campos, (req, res) => {
  console.log('campos de texto:', req.body);
  console.log('campos de arquivo:', Object.keys(req.files));
  res.status(201).json({
    aluno: req.body.nome,
    foto: req.files.foto[0].filename,
    documentos: req.files.documentos.map((d) => d.originalname),
  });
});
{"aluno":"Ana Ribeiro","foto":"aab5c174-02d6-41b2-81e3-94a31b5b7051.png","documentos":["historico-escolar.pdf","comprovante-residencia.pdf"]} campos de texto: [Object: null prototype] { nome: 'Ana Ribeiro', turma: '3A' } campos de arquivo: [ 'foto', 'documentos' ]

Com fields, o arquivo não vem em req.file, e sim em req.files, indexado pelo nome do campo — e sempre como array, mesmo com maxCount: 1. Por isso o req.files.foto[0]. E aqui as duas pastas precisam existir antes: é este o código que devolveu o ENOENT da seção anterior enquanto faltava um fs.mkdirSync('uploads/documentos', { recursive: true }) na subida do servidor.

Agora a armadilha que quase ninguém documenta. O multer processa o corpo na ordem em que as partes chegam, então dentro de filename() o req.body só tem os campos de texto que vieram antes do arquivo:

js
filename: (req, file, cb) => {
  console.log('no filename(), req.body.matricula =', req.body.matricula);
  const matricula = req.body.matricula ?? 'sem-matricula';
  cb(null, `${matricula}${path.extname(file.originalname).toLowerCase()}`);
},
bash
curl -s -F "matricula=2026-0413" -F "foto=@foto-ana.png" http://localhost:5050/alunos/foto
curl -s -F "foto=@foto-ana.png" -F "matricula=2026-0517" http://localhost:5050/alunos/foto
{"salvo":"2026-0413.png"} {"salvo":"sem-matricula.png"} no filename(), req.body.matricula = 2026-0413 no filename(), req.body.matricula = undefined

Mesma API, mesmos dados, resultado diferente só porque a ordem dos campos mudou. É por isso que o nome do arquivo não deve depender de campo de texto: sorteie o nome, e só depois — já no handler, com o req.body completo — associe o arquivo à matrícula no banco.

Disco, memória e o dia em que isso vai para o S3

Trocando diskStorage por memoryStorage, nada toca o disco: o arquivo chega inteiro em req.file.buffer.

js
const upload = multer({ storage: multer.memoryStorage() });

app.post('/alunos/foto', upload.single('foto'), (req, res) => {
  const { buffer, ...resto } = req.file;
  console.log(resto);
  console.log('buffer:', buffer.length, 'bytes | Buffer?', Buffer.isBuffer(buffer));
  console.log('RSS do processo:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB');
  res.status(201).json({ bytes: buffer.length });
});

app.listen(5050, () =>
  console.log('RSS ao subir:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB'),
);

Enviando um backup de 5 MB, num MacBook com Node 24.16.0:

RSS ao subir: 63 MB { fieldname: 'foto', originalname: 'backup-fotos.zip', encoding: '7bit', mimetype: 'application/octet-stream', size: 5242880 } buffer: 5242880 bytes | Buffer? true RSS do processo: 76 MB

Sem path, sem filename, sem destination: em memória esses campos nem existem. E a memória do processo subiu de 63 MB para 76 MB com um upload. Multiplique por dez pessoas enviando ao mesmo tempo e você tem o cálculo que derruba o servidor.

storage onde o arquivo fica quando usar risco
diskStorage disco do servidor processar/servir localmente o disco enche e some no deploy novo
memoryStorage RAM do processo reenviar na hora para outro serviço RAM × uploads simultâneos
multer-s3 e afins direto no bucket produção credencial e custo por requisição

Na Escola Vila Nova de verdade, a pasta uploads/ seria uma bomba-relógio: servidor em container começa do zero a cada deploy, e as fotos iriam junto. Em produção o padrão é memoryStorage (ou multer-s3) enviando para um bucket, e o banco guardando só a chave do objeto. O código desta lição não muda por isso — muda o storage, e ele é uma linha.

O que vem depois

Você já recebe arquivo, limita, confere e devolve a URL. Falta a outra metade da entrada: os campos de texto que vieram no mesmo formulário continuam sem conferência nenhuma. É o assunto de validar a entrada da API com Zod, a próxima lição da trilha de Node — e o lugar certo para exigir que matricula exista, tenha o formato do ano e não venha vazia.

  • upload
  • multer
  • express
  • arquivo
  • seguranca
  • node

Perguntas frequentes

Dá para validar o tamanho do arquivo antes de ele subir?
No navegador, sim: o input de arquivo expõe file.size, e avisar ali evita o upload inteiro. Mas isso é conforto, não segurança — quem chama a sua API pelo curl pula essa checagem. O limite do servidor continua obrigatório.
O multer funciona com TypeScript?
Funciona, instalando @types/multer. O arquivo recebido tem o tipo Express.Multer.File, e req.file fica opcional — o TypeScript vai exigir que você trate o caso de nenhum arquivo ter chegado, que é justamente o caso que costuma quebrar em produção.
Preciso apagar a foto antiga quando o aluno manda uma nova?
Precisa. Nada no multer recicla arquivo. Guarde o nome salvo numa coluna do banco e, ao gravar a foto nova, chame fs.unlink na anterior dentro da mesma transação lógica. Sem isso o disco cresce para sempre.
Como envio o arquivo direto para o S3 em vez do disco?
Troque o storage. Com multer.memoryStorage() o arquivo chega em req.file.buffer e você repassa esse buffer para o SDK do provedor; com o pacote multer-s3 o próprio storage faz o envio. O resto do código — o limite, o fileFilter, o tratamento de erro — continua igual.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, Express 5.2.1, multer 2.2.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. multer — documentação oficial — github.com
  2. RFC 7578 — Returning Values from Forms: multipart/form-data — rfc-editor.org
  3. MDN — o elemento form e o atributo enctype — developer.mozilla.org
  4. PNG Specification — File signature — w3.org

Continue por aqui