Skip to content

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ónCuá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 multer

Ejemplo 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 router
js
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 app
js
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/producto

Desde 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-type
js
// 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 sharp
js
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-s3
js
// 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.originalname como nombre en disco.
  • No permitas subir .html, .svg o .js que 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 ​

  1. Crea un endpoint POST /api/documentos que acepte sólo PDFs de máximo 2 MB en el campo documento.
  2. Crea DELETE /api/archivos/:nombre que borre un archivo de uploads/ 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 MulterError en 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.

Academia Web — aprende, copia y construye.