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.
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:
<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:
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);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:
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:
curl -F "matricula=2026-0413" -F "foto=@foto-ana.png" http://localhost:5050/alunos/foto2026-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.
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'));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:
npm install express multerimport 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.
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:
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:
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 uploadsDois 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:
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:
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:
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:
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:
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/fotoDuas 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:
ls -l uploads/fotosSó 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:
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);
});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:
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:
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/*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:
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:
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/foto4d5a é 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:
app.use('/fotos', express.static('uploads/fotos', { index: false }));curl -I http://localhost:5050/fotos/2cfb96ca-d4db-446e-9054-8b06d1e05f9b.pngO 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.
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());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:
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),
});
});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:
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()}`);
},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/fotoMesma 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.
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:
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.
Perguntas frequentes
Dá para validar o tamanho do arquivo antes de ele subir?
O multer funciona com TypeScript?
Preciso apagar a foto antiga quando o aluno manda uma nova?
Como envio o arquivo direto para o S3 em vez do disco?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- multer — documentação oficial — github.com
- RFC 7578 — Returning Values from Forms: multipart/form-data — rfc-editor.org
- MDN — o elemento form e o atributo enctype — developer.mozilla.org
- PNG Specification — File signature — w3.org


