Tema
Subida de archivos
Fotos de perfil, imágenes de productos, PDFs de facturas... Subir archivos es un requisito en casi cualquier proyecto para clientes. Aprenderás a recibirlos con multer, validar tipo y tamaño, guardarlos con nombres seguros y cuándo conviene usar almacenamiento en la nube.
Concepto
Los formularios con archivos no envían JSON: usan el formato multipart/form-data, que divide el cuerpo en partes (un campo de texto, un archivo, otro archivo...). express.json() no entiende ese formato; para eso existe multer.
Opciones de almacenamiento:
| Opción | Cuándo usarla |
|---|---|
Disco (diskStorage) | VPS propio, proyectos pequeños |
Memoria (memoryStorage) | Para procesar (redimensionar) y luego subir a la nube |
| Nube (S3, Cloudflare R2, Cloudinary) | Producción en Render/Railway/Vercel (su disco es temporal) |
Instalación
bash
npm i express multerEjemplo básico
js
// src/index.js
import express from 'express'
import multer from 'multer'
const app = express()
const upload = multer({ dest: 'uploads/' }) // guarda con nombre aleatorio, sin extensión
// upload.single('campo'): un archivo en el campo "avatar"
app.post('/api/avatar', upload.single('avatar'), (req, res) => {
// req.file: info del archivo; req.body: los demás campos de texto
console.log(req.file)
res.json({ archivo: req.file.filename, nombreOriginal: req.file.originalname })
})
app.listen(3000, () => console.log('http://localhost:3000'))req.file contiene:
js
{
fieldname: 'avatar',
originalname: 'foto vacaciones.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: 'uploads/',
filename: '3f1c9a7b0e2d4c1a8b9e',
path: 'uploads/3f1c9a7b0e2d4c1a8b9e',
size: 184523
}Configuración profesional
js
import multer from 'multer'
import path from 'node:path'
import crypto from 'node:crypto'
import fs from 'node:fs'
const CARPETA = path.resolve('uploads')
fs.mkdirSync(CARPETA, { recursive: true })
// Tipos permitidos: MIME → extensión que usaremos
const TIPOS = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf'
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, CARPETA),
filename: (req, file, cb) => {
// Nombre aleatorio: nunca uses el nombre original (puede traer ../ o sobrescribir)
const nombre = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${TIPOS[file.mimetype]}`
cb(null, nombre)
}
})
function fileFilter(req, file, cb) {
if (TIPOS[file.mimetype]) return cb(null, true)
const error = new Error('Tipo de archivo no permitido (JPG, PNG, WEBP o PDF)')
error.status = 400
cb(error)
}
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB por archivo
files: 5 // máximo 5 archivos por petición
}
})js
import { Router } from 'express'
import { upload } from '../config/upload.js'
const router = Router()
// Un solo archivo
router.post('/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Envía un archivo en el campo "avatar"' })
res.status(201).json({ url: `/uploads/${req.file.filename}` })
})
// Varios archivos en el mismo campo (máx. 5)
router.post('/galeria', upload.array('fotos', 5), (req, res) => {
const urls = req.files.map((f) => `/uploads/${f.filename}`)
res.status(201).json({ urls })
})
// Campos distintos
router.post(
'/producto',
upload.fields([
{ name: 'portada', maxCount: 1 },
{ name: 'fotos', maxCount: 4 }
]),
(req, res) => {
const { nombre, precio } = req.body // campos de texto del formulario
res.status(201).json({
nombre,
precio: Number(precio),
portada: req.files.portada?.[0]?.filename,
fotos: req.files.fotos?.map((f) => f.filename) ?? []
})
}
)
export default routerjs
import express from 'express'
import multer from 'multer'
import path from 'node:path'
import archivosRouter from './routes/archivos.routes.js'
const app = express()
// Sirve los archivos subidos como estáticos
app.use('/uploads', express.static(path.resolve('uploads'), { maxAge: '7d' }))
app.use('/api/archivos', archivosRouter)
// Manejo de errores de multer
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const mensajes = {
LIMIT_FILE_SIZE: 'El archivo supera los 5 MB',
LIMIT_FILE_COUNT: 'Demasiados archivos',
LIMIT_UNEXPECTED_FILE: `Campo de archivo inesperado: ${err.field}`
}
return res.status(400).json({ error: mensajes[err.code] ?? err.message })
}
res.status(err.status ?? 500).json({ error: err.status ? err.message : 'Error interno' })
})
export default appjs
import app from './app.js'
app.listen(3000, () => console.log('http://localhost:3000'))Probar la subida
Con curl:
bash
curl -F "avatar=@./foto.jpg" http://localhost:3000/api/archivos/avatar
curl -F "fotos=@a.jpg" -F "fotos=@b.png" http://localhost:3000/api/archivos/galeria
curl -F "nombre=Taza" -F "precio=120" -F "portada=@taza.jpg" http://localhost:3000/api/archivos/productoDesde el frontend con FormData:
html
<form id="form-avatar">
<input type="file" name="avatar" accept="image/*" required>
<button>Subir</button>
</form>
<img id="vista" alt="" width="120">
<script type="module">
const form = document.querySelector('#form-avatar')
form.addEventListener('submit', async (e) => {
e.preventDefault()
const datos = new FormData(form) // incluye el archivo
// ¡No pongas Content-Type! El navegador agrega el boundary correcto
const res = await fetch('http://localhost:3000/api/archivos/avatar', {
method: 'POST',
body: datos
})
const json = await res.json()
if (!res.ok) return alert(json.error)
document.querySelector('#vista').src = 'http://localhost:3000' + json.url
})
</script>Verificar el contenido real
El mimetype lo envía el cliente y se puede falsificar (renombrar un .exe a .jpg). Para mayor seguridad, revisa los primeros bytes del archivo (magic numbers) con file-type:
bash
npm i file-typejs
// src/middlewares/verificarTipo.js
import { fileTypeFromFile } from 'file-type'
import { unlink } from 'node:fs/promises'
const PERMITIDOS = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
export async function verificarTipo(req, res, next) {
const archivos = req.file ? [req.file] : Object.values(req.files ?? {}).flat()
for (const archivo of archivos) {
const tipo = await fileTypeFromFile(archivo.path)
if (!tipo || !PERMITIDOS.includes(tipo.mime)) {
await Promise.all(archivos.map((a) => unlink(a.path).catch(() => {})))
return res.status(400).json({ error: `Contenido no válido en ${archivo.originalname}` })
}
}
next()
}
// Uso: router.post('/avatar', upload.single('avatar'), verificarTipo, controlador)Redimensionar imágenes con sharp
Para no servir fotos de 8 MB desde el celular:
bash
npm i sharpjs
import multer from 'multer'
import sharp from 'sharp'
import path from 'node:path'
import crypto from 'node:crypto'
const uploadMemoria = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => cb(null, file.mimetype.startsWith('image/'))
})
router.post('/foto-producto', uploadMemoria.single('foto'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Falta la imagen' })
const nombre = `${crypto.randomUUID()}.webp`
await sharp(req.file.buffer)
.rotate() // respeta la orientación del celular
.resize({ width: 1200, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(path.resolve('uploads', nombre))
res.status(201).json({ url: `/uploads/${nombre}` })
})Almacenamiento en la nube (S3 / R2)
En plataformas como Render o Railway el disco se borra en cada despliegue. Sube a un servicio de objetos compatible con S3 (AWS S3, Cloudflare R2, DigitalOcean Spaces):
bash
npm i @aws-sdk/client-s3js
// src/services/storage.js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import crypto from 'node:crypto'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // ej. https://<cuenta>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY
}
})
export async function subirArchivo(buffer, contentType, extension) {
const clave = `uploads/${crypto.randomUUID()}${extension}`
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: clave,
Body: buffer,
ContentType: contentType
}))
return `${process.env.S3_PUBLIC_URL}/${clave}`
}Buena práctica
- Guarda en la base de datos sólo la URL o clave del archivo, nunca el archivo binario.
- Pon límites de tamaño y cantidad siempre.
- Al eliminar un registro (producto, usuario), borra también sus archivos.
Seguridad
- Nunca uses
file.originalnamecomo nombre en disco. - No permitas subir
.html,.svgo.jsque luego se sirvan desde tu dominio: podrían ejecutar scripts (XSS). - No sirvas la carpeta de subidas con permisos de ejecución en el servidor.
Ejercicios
- Crea un endpoint
POST /api/documentosque acepte sólo PDFs de máximo 2 MB en el campodocumento. - Crea
DELETE /api/archivos/:nombreque borre un archivo deuploads/validando que el nombre no contenga rutas (..,/).
Ver solución
js
// 1
const uploadPdf = multer({
storage, // el diskStorage de config/upload.js (o uno propio)
limits: { fileSize: 2 * 1024 * 1024, files: 1 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') return cb(null, true)
const e = new Error('Sólo se permiten archivos PDF')
e.status = 400
cb(e)
}
})
router.post('/documentos', uploadPdf.single('documento'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Falta el PDF' })
res.status(201).json({ url: `/uploads/${req.file.filename}`, tamaño: req.file.size })
})js
// 2
import { unlink } from 'node:fs/promises'
import path from 'node:path'
router.delete('/:nombre', async (req, res) => {
const { nombre } = req.params
// path.basename elimina cualquier carpeta; si cambia, el nombre traía una ruta
if (path.basename(nombre) !== nombre || nombre.startsWith('.')) {
return res.status(400).json({ error: 'Nombre inválido' })
}
try {
await unlink(path.resolve('uploads', nombre))
res.sendStatus(204)
} catch (err) {
if (err.code === 'ENOENT') return res.status(404).json({ error: 'No existe' })
throw err
}
})En una app real, además verifica que el usuario autenticado sea el dueño del archivo.
Resumen
- Los archivos llegan como
multipart/form-data: usa multer (single,array,fields). - Valida tipo (
fileFilter+file-type), tamaño (limits) y genera nombres aleatorios. - Maneja
MulterErroren el middleware de errores. - En producción en la nube, guarda los archivos en S3/R2 y sólo la URL en la base de datos.