Express a fondo: el framework de Node.js de toda la vida, en su versión 5
Historia, cómo funciona por dentro, qué cambia en Express 5, descarga e instalación, middleware, validación, JWT, subidas, tests, TypeScript, Docker y una guía de migración desde Express 4, con todo el código probado.
Contenido
Si has escrito alguna vez un servidor en Node.js, lo más probable es que empezaras con Express. Es el framework web más descargado del ecosistema JavaScript: decenas de millones de descargas semanales en npm, miles de paquetes de middleware y una API tan pequeña que cabe en una servilleta. Durante una década fue sinónimo de "backend en Node".
También arrastraba fama de viejo. La versión 4 salió en 2014 y la 5 pasó diez años en alfa y beta. Mientras tanto aparecieron Fastify, Koa, NestJS y, más recientemente, Hono, todos prometiendo más velocidad, mejor soporte de async/await o TypeScript de serie. Pero en septiembre de 2024 llegó por fin Express 5, en marzo de 2025 se convirtió en la versión por defecto de npm y hoy, con la 5.2.1, es la versión estable y mantenida.
Este artículo es la guía que me habría gustado tener: de dónde viene, cómo funciona por dentro, qué cambia exactamente en la versión 5, dónde descargarlo, y un recorrido largo por el código que escribes de verdad en una API: rutas, middleware, errores, validación con Zod, autenticación con JWT, subida de ficheros, tests, TypeScript, Docker y despliegue detrás de un proxy. Al final hay una guía de migración desde Express 4 con el código de antes y el de después.
Un poco de historia
Express nació en 2010 de la mano de TJ Holowaychuk, un desarrollador canadiense tremendamente prolífico (también escribió Koa, Mocha, Commander, Jade/Pug y cientos de módulos más). Se inspiró en Sinatra, el microframework de Ruby: nada de generadores de proyectos gigantes ni convenciones rígidas, solo una forma cómoda de decir "cuando llegue un GET a esta ruta, ejecuta esta función".
La pieza clave vino de Connect, una librería anterior del mismo entorno que introdujo la idea de encadenar funciones (req, res, next): el famoso middleware. Express 3 se construía sobre Connect; Express 4, en 2014, se deshizo de esa dependencia, reescribió el router y sacó del núcleo casi todo lo que no era imprescindible (el parser del cuerpo, las cookies, las sesiones) a paquetes independientes. Esa es la forma que conocemos hoy.
En 2014 TJ dejó el mundo de Node y vendió los derechos del proyecto a StrongLoop, que después compró IBM. La comunidad lo vivió regular, y en 2016 Express pasó a la Node.js Foundation, que en 2019 se fusionó con la JS Foundation para formar la OpenJS Foundation, donde está hoy junto a Node.js, jQuery, webpack o ESLint. Hoy lo mantienen voluntarios organizados en un comité técnico (TC) y un equipo de "capitanes" por repositorio.
Los diez años de Express 5
La primera alfa de Express 5 se publicó en noviembre de 2014. La versión final llegó el 9 de septiembre de 2024. Por el camino hubo ocho alfas, tres betas, cambios de mantenedores y épocas enteras sin actividad. La razón no era técnica sino humana: muy pocas personas mantenían un paquete del que depende medio Internet, y cualquier cambio incompatible rompe millones de proyectos.
En 2024 el proyecto se reactivó con un plan claro: publicar la 5.0 con los cambios ya hechos, subir la versión mínima de Node a la 18, limpiar dependencias, auditar la seguridad y establecer un calendario de soporte. El resultado es una versión 5 que no revoluciona nada, pero que arregla lo que más dolía: los errores en funciones async y un sistema de rutas con problemas de seguridad.
| Fecha | Hito |
|---|---|
| 2010 | TJ Holowaychuk publica Express, inspirado en Sinatra y construido sobre Connect. |
| Abril 2014 | Express 4: router nuevo, sin Connect, middleware fuera del núcleo. |
| Noviembre 2014 | Primera alfa de Express 5. |
| 2016 | El proyecto pasa a la Node.js Foundation (hoy OpenJS Foundation). |
| Septiembre 2024 | Express 5.0.0, diez años después de su primera alfa. |
| Marzo 2025 | Express 5.1.0 pasa a ser la versión latest de npm: npm i express ya instala la 5. |
| Diciembre 2025 | Express 5.2.0 (arreglo de seguridad) y 5.2.1 (revierte un cambio del parser de query que no debía entrar). |
Descarga e instalación
Express es un paquete de npm, así que no hay instalador: se añade como dependencia a tu proyecto. Estos son los enlaces oficiales:
- Paquete en npm: npmjs.com/package/express, la "descarga" oficial.
- Código fuente: github.com/expressjs/express, con las notas de cada versión.
- Web y documentación: expressjs.com, con la referencia de la API 5.x y la guía de migración a Express 5.
- Node.js: nodejs.org. Express 5 necesita Node.js 18 o superior; usa una versión LTS (22 o 24).
- Middleware oficial: github.com/expressjs (cors, morgan, compression, multer, session, cookie-parser…).
Requisitos
- Node.js 18 o superior (compruébalo con
node -v). Lo he probado con la 20.20. - npm (viene con Node), pnpm, yarn o bun.
- Un editor. Si usas TypeScript, los tipos van aparte:
@types/express.
Instalarlo
# Proyecto nuevo
mkdir mi-api && cd mi-api
npm init -y
npm pkg set type=module # usar import/export (módulos ES)
# Express 5 (la versión "latest" de npm)
npm install express
# Comprobar qué versión se ha instalado
npm ls express
# [email protected]
# └── [email protected]Con otros gestores de paquetes es igual de simple:
pnpm add express
yarn add express
bun add express
# Tipos para TypeScript (Express no los trae dentro)
npm install -D @types/express @types/nodenpm install express instalaba Express 4 y había que pedir express@5 explícitamente. Ahora es al revés: si mantienes un proyecto viejo y quieres seguir en la 4 (que recibe parches de seguridad), fija la versión con npm install express@4.El generador oficial (y su trampa)
Existe express-generator, que crea un esqueleto con carpetas y un sistema de plantillas. Funciona, pero lleva sin versión nueva desde hace años y genera un proyecto con Express 4.16:
# Genera un esqueleto clásico (vistas, public/, routes/, bin/www)
npx express-generator --no-view mi-app
# OJO: la versión 4.16.1 del generador escribe "express": "~4.16.1" en package.json.
# Si lo usas, actualiza a mano:
cd mi-app && npm install express@5Mi consejo: no lo uses. Un proyecto de Express empieza con un solo fichero, y la estructura de carpetas que propongo más abajo es más moderna (módulos ES, node --watch, node --test).
El ejemplo más pequeño: hola mundo
Crea hola.js con esto. Es una aplicación Express completa:
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('¡Hola desde Express 5!');
});
app.get('/api/saludo/:nombre', (req, res) => {
res.json({ mensaje: `Hola, ${req.params.nombre}` });
});
app.listen(PORT, () => {
console.log(`Servidor escuchando en http://localhost:${PORT}`);
});Y pruébala:
node hola.js
# Servidor escuchando en http://localhost:3000
curl http://localhost:3000/
# ¡Hola desde Express 5!
curl http://localhost:3000/api/saludo/Luis
# {"mensaje":"Hola, Luis"}
# Desarrollo: reinicia solo al guardar (Node 18.11+), sin nodemon
node --watch hola.jsTres ideas que ya están aquí y que se repiten en todo Express:
express()crea una aplicación, que en el fondo es una función(req, res)que Node puede usar como manejador de su servidor HTTP.app.get(ruta, manejador)registra una ruta para un verbo HTTP. Hayget,post,put,patch,delete,options,headyall.- El manejador recibe la petición (
req) y la respuesta (res).res.send()envía texto o HTML;res.json()serializa un objeto y pone la cabeceraContent-Type: application/json.
Características principales
- Minimalista y sin opiniones: no impone estructura de carpetas, ORM ni motor de plantillas. Tú decides.
- Middleware: todo (autenticación, logs, parseo del cuerpo, CORS…) se monta como funciones encadenadas
(req, res, next). - Router potente: rutas con parámetros, comodines, routers anidados y montados en prefijos.
- Extiende
reqyrescon utilidades (req.params,req.ip,res.json(),res.redirect(),res.sendFile()…) sin esconder los objetos de Node. - Promesas de serie (Express 5): una función
asyncque lanza un error lo manda al manejador de errores. - Negociación de contenido, cookies, redirecciones y ficheros estáticos integrados.
- Motores de plantillas enchufables (EJS, Pug, Handlebars…) mediante
res.render(). - El mayor ecosistema de Node: helmet, cors, passport, multer, express-session, express-rate-limit, morgan… Casi cualquier problema tiene ya un middleware.
- Estable y conocido: es lo que más gente sabe usar, lo que más tutoriales tiene y lo que más respuestas tiene en Stack Overflow. También es la base de NestJS por defecto.
Y lo que no trae, conviene saberlo desde el principio: validación de datos, serialización con esquemas, documentación OpenAPI, inyección de dependencias, tipos de TypeScript integrados o un sistema de plugins encapsulado. Todo eso existe, pero lo añades tú con otras librerías. Es justo donde Fastify, NestJS o Hono le sacan ventaja; lo comparo en detalle en el artículo comparativo.
Cómo funciona por dentro
Para usar bien Express hay que entender que no hay magia. Todo el framework es una lista de funciones que se ejecutan en orden. Vamos por capas.

La aplicación es una función
Cuando haces app.listen(3000), Express hace exactamente esto por dentro: http.createServer(app).listen(3000). Tu app es una función que Node llama con cada petición. Por eso puedes montar una app de Express dentro de un servidor HTTPS propio, dentro de otro framework o dentro de supertest para los tests: es solo una función (req, res).
El Router y sus capas
Cada vez que llamas a app.use(), app.get() o router.post(), Express crea una capa (Layer) y la añade al final de una lista, la pila del router. Cada capa guarda dos cosas: un patrón de ruta y una función.
app.use(fn)crea una capa que coincide con cualquier ruta que empiece por el prefijo (por defecto/, es decir, todas).app.get('/tareas/:id', fn)crea una capa que contiene una Route, y la Route guarda una lista de manejadores por verbo. Solo coincide si la ruta es exacta y el verbo es el correcto.app.use('/api', router)mete un router entero como si fuera una función más. Dentro,req.urlse recorta: el router ve/tareasaunque la URL real sea/api/tareas(la completa sigue enreq.originalUrl, y el prefijo enreq.baseUrl).
Cuando llega una petición, el router recorre la pila de arriba abajo y ejecuta la primera capa que coincide. Esa función decide: o responde, o llama a next() para que el router siga buscando la siguiente capa que coincida. Por eso el orden en el que registras las cosas importa: si pones el 404 antes que tus rutas, todo será 404.
path-to-regexp: de texto a coincidencia
Los patrones como /tareas/:id se compilan con path-to-regexp, una librería del propio equipo. Express 5 usa la versión 8, que es mucho más estricta que la 0.1 de Express 4: ya no admite expresiones regulares dentro de la ruta, los comodines necesitan nombre y los opcionales se escriben con llaves. Lo vemos en detalle en la sección de Express 5. La razón del cambio es la seguridad: la sintaxis antigua permitía rutas que generaban expresiones regulares con backtracking catastrófico (ReDoS), capaces de bloquear el proceso con una URL maliciosa.
req y res extendidos
Los objetos req y res son los de Node (http.IncomingMessage y http.ServerResponse). Express no los envuelve: les cambia el prototipo para añadir métodos. Por eso res.json() y res.write() conviven, y por eso cualquier librería que funcione con el módulo http de Node funciona con Express. Lo que añade:
En req | Qué es | En res | Qué hace |
|---|---|---|---|
req.params | Parámetros de la ruta (:id) | res.status(201) | Fija el código (encadenable) |
req.query | La query string ya parseada | res.json(obj) | Envía JSON |
req.body | El cuerpo, si hay un parser montado | res.send(x) | Envía texto, HTML o Buffer |
req.ip, req.ips | IP del cliente (respeta trust proxy) | res.set('X', 'y') | Pone cabeceras |
req.get('Cabecera') | Lee una cabecera sin importar mayúsculas | res.redirect(301, url) | Redirige |
req.is('json') | Comprueba el Content-Type | res.sendFile(ruta) | Envía un fichero con caché y rangos |
req.hostname, req.host | Dominio (host incluye el puerto) | res.cookie() | Pone una cookie |
req.protocol, req.secure | http o https | res.render(vista) | Renderiza una plantilla |
req.originalUrl, req.baseUrl | URL completa y prefijo del router | res.sendStatus(204) | Código y texto estándar |
Todo lo demás son dependencias pequeñas
Express 5.2.1 son unas 30 dependencias directas, casi todas del propio proyecto jshttp/pillarjs: router (el router, que ahora es un paquete aparte, versión 2.2), body-parser (2.3), path-to-regexp (8.4), send y serve-static (ficheros), qs (query strings), cookie, proxy-addr (IP real tras un proxy), finalhandler (la respuesta cuando nadie responde)… Es un framework hecho de piezas pequeñas y reutilizables, y eso explica su estabilidad.
Rutas
Este fichero reúne casi todo lo que harás con rutas en el día a día. Lo he probado con curl, y la salida de cada petición está en los comentarios:
import express from 'express';
const app = express();
// Métodos HTTP: uno por verbo
app.get('/productos', (req, res) => res.json({ accion: 'listar' }));
app.post('/productos', (req, res) => res.status(201).json({ accion: 'crear' }));
// Parámetros de ruta: /productos/42 → req.params.id === '42'
app.get('/productos/:id', (req, res) => {
res.json({ id: Number(req.params.id) });
});
// Varios parámetros y un guion o un punto como separadores
app.get('/vuelos/:origen-:destino', (req, res) => {
res.json(req.params); // { origen: 'MAD', destino: 'BCN' }
});
// Express 5: parte opcional con llaves → /informe y /informe.pdf
app.get('/informe{.:formato}', (req, res) => {
res.json({ formato: req.params.formato ?? 'html' });
});
// Express 5: comodín con nombre → req.params.ruta es un array
app.get('/ficheros/*ruta', (req, res) => {
res.json({ ruta: req.params.ruta.join('/') });
});
// Query string: /buscar?q=teclado&pagina=2
app.get('/buscar', (req, res) => {
const { q = '', pagina = '1' } = req.query;
res.json({ q, pagina: Number(pagina) });
});
// app.route(): varios verbos sobre la misma ruta sin repetirla
app.route('/carrito')
.get((req, res) => res.json({ articulos: [] }))
.delete((req, res) => res.sendStatus(204));
// Respuestas habituales
app.get('/respuestas/texto', (req, res) => res.type('text').send('texto plano'));
app.get('/respuestas/redirigir', (req, res) => res.redirect(301, '/productos'));
app.get('/respuestas/cabecera', (req, res) => {
res.set('X-Hecho-Con', 'Express 5').status(202).json({ ok: true });
});
// Todo lo que no ha coincidido: 404 en JSON
app.use((req, res) => {
res.status(404).json({ error: `No existe ${req.method} ${req.path}` });
});
app.listen(3001, () => console.log('rutas en :3001'));Las respuestas reales al probarlo:
curl localhost:3001/productos/42 # {"id":42}
curl localhost:3001/vuelos/MAD-BCN # {"origen":"MAD","destino":"BCN"}
curl localhost:3001/informe # {"formato":"html"}
curl localhost:3001/informe.pdf # {"formato":"pdf"}
curl localhost:3001/ficheros/a/b/c.txt # {"ruta":"a/b/c.txt"}
curl "localhost:3001/buscar?q=teclado&pagina=2" # {"q":"teclado","pagina":2}
curl -X DELETE localhost:3001/carrito # 204 sin cuerpo
curl localhost:3001/nada # 404 {"error":"No existe GET /nada"}- Los parámetros de ruta y de query siempre llegan como texto.
Number(), o mejor un esquema de validación, los convierte. - Las rutas no distinguen mayúsculas ni la barra final por defecto:
/Mayus/coincide con/mayus. Puedes cambiarlo conapp.set('case sensitive routing', true)yapp.set('strict routing', true). res.sendStatus(204)envía el código y su texto estándar; con 204 no envía cuerpo.
Routers modulares
En cuanto la API crece, cada recurso va a su fichero con su propio Router. Un router es una mini aplicación: tiene su pila, sus middleware y sus rutas, y se monta en un prefijo con app.use(). Lo verás en el proyecto completo más abajo (src/routes/tareas.js), incluido router.param(), que ejecuta una función cada vez que una ruta del router tiene un parámetro concreto, perfecta para cargar el recurso de la base de datos una sola vez.
Middleware: el corazón de Express

Un middleware es cualquier función (req, res, next). Puede leer y modificar req y res, terminar la petición respondiendo o pasar el control con next(). Hay cinco tipos, y este ejemplo tiene todos:
import express from 'express';
const app = express();
// 1. Middleware de aplicación: se ejecuta en TODAS las peticiones
app.use((req, res, next) => {
const inicio = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${ms.toFixed(1)} ms)`);
});
next(); // sin esto, la petición se queda colgada
});
// 2. Middleware integrados
app.use(express.json({ limit: '100kb' })); // application/json → req.body
app.use(express.urlencoded({ extended: false })); // formularios HTML → req.body
app.use('/estaticos', express.static('public', { maxAge: '1d' }));
// 3. Middleware que solo se aplica a un prefijo
app.use('/admin', (req, res, next) => {
if (req.get('x-api-key') !== 'secreto') {
return res.status(401).json({ error: 'Falta la clave' });
}
next();
});
app.get('/admin/panel', (req, res) => res.json({ panel: 'ok' }));
// 4. Middleware en una sola ruta (y en cadena)
const cargarUsuario = (req, res, next) => {
req.usuario = { id: 7, nombre: 'Ana' }; // lo que pongas en req llega a los siguientes
next();
};
const soloAna = (req, res, next) =>
req.usuario.nombre === 'Ana' ? next() : res.sendStatus(403);
app.get('/perfil', cargarUsuario, soloAna, (req, res) => res.json(req.usuario));
app.post('/eco', (req, res) => res.json({ recibido: req.body }));
// 5. Express 5: una función async que lanza (o una promesa rechazada)
// llega sola al manejador de errores. Sin try/catch, sin next(err).
app.get('/fallo-async', async (req, res) => {
await new Promise((r) => setTimeout(r, 10));
throw new Error('La base de datos no responde');
});
// Errores con código propio
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
app.get('/pedidos/:id', async (req, res) => {
const pedido = null; // simulamos que no existe
if (!pedido) throw new HttpError(404, `El pedido ${req.params.id} no existe`);
res.json(pedido);
});
// 6. 404: nadie ha respondido
app.use((req, res) => res.status(404).json({ error: 'No encontrado' }));
// 7. Manejador de errores: CUATRO argumentos, siempre al final
app.use((err, req, res, next) => {
if (res.headersSent) return next(err); // deja que Express cierre la conexión
const status = err.status ?? err.statusCode ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({
error: status >= 500 ? 'Error interno' : err.message,
});
});
app.listen(3002, () => console.log('middleware en :3002'));Lo que sale al probarlo (el log lo escribe nuestro primer middleware, midiendo el tiempo con res.on('finish')):
curl localhost:3002/admin/panel # {"error":"Falta la clave"}
curl -H 'x-api-key: secreto' localhost:3002/admin/panel # {"panel":"ok"}
curl -X POST -d 'nombre=Luis&edad=30' localhost:3002/eco # {"recibido":{"nombre":"Luis","edad":"30"}}
curl -X POST -H 'content-type: application/json' -d '{mal json' localhost:3002/eco
# {"error":"Expected property name or '}' in JSON at position 1"} ← 400, lo detecta express.json()
curl localhost:3002/fallo-async # {"error":"Error interno"} ← 500, sin try/catch
curl localhost:3002/pedidos/9 # {"error":"El pedido 9 no existe"} ← 404 con HttpError
# En la consola del servidor:
# GET /admin/panel → 401 (5.3 ms)
# POST /eco → 400 (1.5 ms)
# Error: La base de datos no responde
# at file:///…/middleware.js:45:9
# GET /fallo-async → 500 (13.8 ms)Las reglas que hay que tener claras
- El orden es el orden de registro. Los parsers y la seguridad arriba; las rutas en medio; el 404 y el manejador de errores, al final del todo.
- Un middleware tiene que responder o llamar a
next(). Si no hace ninguna de las dos cosas, la petición se queda abierta sin respuesta. - Responder y después llamar a
next()es un error clásico. Si otro middleware intenta responder, verás Cannot set headers after they are sent to the client. Usareturn res.json(…)para cortar. - El manejador de errores se reconoce por tener cuatro parámetros
(err, req, res, next). Aunque no usesnext, tiene que estar en la firma: Express mirafn.length. next('route')salta el resto de manejadores de la ruta actual y pasa a la siguiente ruta que coincida.next('router')sale del router actual.- Lo que pones en
reqviaja. Es la forma idiomática de pasar datos entre middleware (req.usuario,req.tarea…).
Los middleware integrados
| Middleware | Para qué | Opciones útiles |
|---|---|---|
express.json() | Cuerpos application/json → req.body | limit (100kb por defecto), strict, type |
express.urlencoded() | Formularios HTML clásicos | extended (en Express 5, false por defecto), limit |
express.text() | Cuerpos de texto plano | type, defaultCharset |
express.raw() | Cuerpo como Buffer (webhooks que verifican la firma, p. ej. Stripe) | type: 'application/json' |
express.static() | Servir una carpeta de ficheros | maxAge, index, dotfiles (ignore por defecto) |
Los middleware de terceros que casi siempre instalo
- [helmet](https://www.npmjs.com/package/helmet): una docena de cabeceras de seguridad (Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options…) con una línea.
- [cors](https://www.npmjs.com/package/cors): responde a las peticiones preflight y pone las cabeceras CORS. Configura siempre
origincon una lista;*con credenciales no funciona y además es mala idea. - [morgan](https://www.npmjs.com/package/morgan): log de cada petición. Formato
deven desarrollo (con colores) ycombineden producción (el de Apache). - [compression](https://www.npmjs.com/package/compression): gzip/deflate de las respuestas. Si tienes Traefik, Nginx o Cloudflare delante, que comprima el proxy y te lo ahorras.
- [express-rate-limit](https://www.npmjs.com/package/express-rate-limit): limita peticiones por IP. Imprescindible en el login. Con varias réplicas, usa un almacén compartido (Redis).
- [multer](https://www.npmjs.com/package/multer): subida de ficheros (
multipart/form-data). - [cookie-parser](https://www.npmjs.com/package/cookie-parser) y [express-session](https://www.npmjs.com/package/express-session): cookies firmadas y sesiones en servidor, si no usas tokens.
Express 5: errores async sin try/catch
Este es el cambio que justifica por sí solo actualizar. En Express 4, si una función async lanzaba un error, Express no se enteraba: la promesa rechazada quedaba sin capturar. En versiones modernas de Node, una promesa rechazada sin capturar tumba el proceso entero. Lo he comprobado con Express 4.22 y este código:
const express = require('express');
const app = express();
app.get('/datos', async (req, res) => { throw new Error('falló la consulta'); });
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
app.listen(3016);$ curl -w 'code=%{http_code}\n' localhost:3016/datos
code=000 ← el servidor cierra la conexión sin responder
# Y en la consola: Error: falló la consulta … Node.js v20.20.2 → el proceso ha muertoPor eso en Express 4 todo el mundo acababa escribiendo try { … } catch (err) { next(err) } en cada ruta o instalando express-async-errors o un envoltorio asyncHandler. En Express 5 el router mira si el manejador devuelve una promesa, y si se rechaza, llama él a next(err). Funciona en rutas, en middleware y en router.param():
// Express 5: basta con esto
app.get('/tareas/:id', async (req, res) => {
const tarea = await db.tareas.buscar(req.params.id); // si lanza → manejador de errores
if (!tarea) throw new HttpError(404, 'No existe'); // también
res.json(tarea);
});setTimeout, de un callback de un evento o de una promesa que no esperas con await, sigue siendo un error sin capturar. Usa await siempre, y en Node 15+ vigila process.on('unhandledRejection') en los logs.Proyecto completo: una API de tareas
Vamos con algo real: una API REST con autenticación JWT, validación con Zod, roles, paginación, seguridad, logs, límites de peticiones, manejo de errores centralizado, tests, apagado ordenado y Docker. La estructura:
api-tareas/
├── package.json
├── Dockerfile
├── .dockerignore
├── src/
│ ├── server.js # arranca el servidor y gestiona el apagado
│ ├── app.js # crea y configura la app (sin listen: se puede testear)
│ ├── config.js # variables de entorno
│ ├── errors.js # clase HttpError
│ ├── routes/
│ │ ├── auth.js # POST /api/auth/login
│ │ └── tareas.js # CRUD /api/tareas
│ ├── middleware/
│ │ ├── auth.js # JWT: requiereAuth, requiereRol
│ │ ├── validar.js # validación con Zod
│ │ └── errores.js # 404 y manejador de errores
│ └── services/
│ └── tareas.js # acceso a datos
└── test/
└── tareas.test.js # node:test + supertestnpm install express helmet cors morgan compression express-rate-limit zod jsonwebtoken
npm install -D supertest
# package.json → "scripts"
# "start": "node src/server.js",
# "dev": "node --watch src/server.js",
# "test": "NODE_ENV=test node --test"La decisión más importante de la estructura: separar app.js de server.js. app.js construye la aplicación y la exporta sin abrir ningún puerto; server.js la arranca. Así los tests pueden importar la app y lanzarle peticiones sin ocupar puertos ni arrancar nada.
Configuración y errores
// src/config.js
// Toda la configuración sale de variables de entorno, con valores por defecto para desarrollo
export const config = {
port: Number(process.env.PORT ?? 3000),
env: process.env.NODE_ENV ?? 'development',
jwtSecret: process.env.JWT_SECRET ?? 'solo-para-desarrollo-cambiame',
corsOrigin: process.env.CORS_ORIGIN?.split(',') ?? ['http://localhost:5173'],
};
if (config.env === 'production' && config.jwtSecret.startsWith('solo-para')) {
throw new Error('Define JWT_SECRET en producción');
}// src/errors.js
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}La clase HttpError es diminuta pero cambia la forma de escribir los controladores: en vez de repetir res.status(404).json(…) en veinte sitios, haces throw new HttpError(404, '…') y el manejador de errores se encarga del formato. Con los errores async de Express 5, funciona incluso dentro de funciones async.
La capa de datos
// src/services/tareas.js
import { randomUUID } from 'node:crypto';
// Almacén en memoria. En un proyecto real, aquí irían las consultas
// a PostgreSQL (con pg, Drizzle, Prisma…). Las funciones son async
// para que el resto del código no cambie el día que cambies de base de datos.
const tareas = new Map();
export async function listar({ hecha, pagina = 1, porPagina = 20 } = {}) {
let lista = [...tareas.values()];
if (hecha !== undefined) lista = lista.filter((t) => t.hecha === hecha);
const inicio = (pagina - 1) * porPagina;
return { total: lista.length, pagina, datos: lista.slice(inicio, inicio + porPagina) };
}
export async function obtener(id) {
return tareas.get(id) ?? null;
}
export async function crear({ titulo, hecha = false }, autor) {
const tarea = { id: randomUUID(), titulo, hecha, autor, creada: new Date().toISOString() };
tareas.set(tarea.id, tarea);
return tarea;
}
export async function actualizar(id, cambios) {
const tarea = tareas.get(id);
if (!tarea) return null;
Object.assign(tarea, cambios);
return tarea;
}
export async function borrar(id) {
return tareas.delete(id);
}
export function vaciar() {
tareas.clear();
}Validación con Zod
Express no valida nada. Nunca te fíes de req.body: puede traer cualquier cosa, con cualquier tipo. Este middleware genérico valida con cualquier esquema de Zod y deja los datos limpios y convertidos en req.validado:
// src/middleware/validar.js
import { HttpError } from '../errors.js';
// Valida body, query o params con un esquema de Zod.
// Si es válido, sustituye el valor por el ya convertido (números, booleanos, valores por defecto).
export const validar = (esquema, origen = 'body') => (req, res, next) => {
const resultado = esquema.safeParse(req[origen]);
if (!resultado.success) {
throw new HttpError(400, 'Datos no válidos', resultado.error.issues.map((i) => ({
campo: i.path.join('.'),
mensaje: i.message,
})));
}
// En Express 5, req.query es un getter: no se puede reasignar. Guardamos aparte.
req.validado = { ...req.validado, [origen]: resultado.data };
next();
};¿Por qué no sobrescribir req.query con los datos convertidos, como se hacía en Express 4? Porque en Express 5 req.query es un getter y no se puede reasignar:
// Express 5: req.query es un getter; no se puede reasignar
app.get('/', (req, res) => {
req.query = { pagina: 1 };
// TypeError: Cannot set property query of #<IncomingMessage> which has only a getter
});
// Guarda los datos ya validados en otra propiedad
req.validado = { query: datosLimpios };Autenticación con JWT
// src/middleware/auth.js
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { HttpError } from '../errors.js';
export function firmarToken(usuario) {
return jwt.sign({ sub: usuario.id, nombre: usuario.nombre, rol: usuario.rol }, config.jwtSecret, {
expiresIn: '1h',
});
}
// Exige "Authorization: Bearer <token>" y deja el usuario en req.usuario
export function requiereAuth(req, res, next) {
const [tipo, token] = (req.get('authorization') ?? '').split(' ');
if (tipo !== 'Bearer' || !token) throw new HttpError(401, 'Falta el token');
try {
req.usuario = jwt.verify(token, config.jwtSecret);
} catch {
throw new HttpError(401, 'Token no válido o caducado');
}
next();
}
export const requiereRol = (rol) => (req, res, next) => {
if (req.usuario?.rol !== rol) throw new HttpError(403, 'No tienes permiso');
next();
};// src/routes/auth.js
import { Router } from 'express';
import { z } from 'zod';
import { validar } from '../middleware/validar.js';
import { firmarToken } from '../middleware/auth.js';
import { HttpError } from '../errors.js';
// Usuarios de ejemplo. En la vida real: base de datos y contraseñas con argon2 o bcrypt.
const USUARIOS = [
{ id: 1, nombre: 'ana', clave: 'ana123', rol: 'admin' },
{ id: 2, nombre: 'luis', clave: 'luis123', rol: 'usuario' },
];
const esquemaLogin = z.object({
nombre: z.string().min(1),
clave: z.string().min(1),
});
export const authRouter = Router();
authRouter.post('/login', validar(esquemaLogin), async (req, res) => {
const { nombre, clave } = req.validado.body;
const usuario = USUARIOS.find((u) => u.nombre === nombre && u.clave === clave);
if (!usuario) throw new HttpError(401, 'Usuario o contraseña incorrectos');
res.json({ token: firmarToken(usuario) });
});JWT_SECRET largo y aleatorio fuera del código, tokens de vida corta y, si el cliente es un navegador, considera cookies httpOnly + secure + sameSite en lugar de guardar el token en localStorage.El router del recurso
// src/routes/tareas.js
import { Router } from 'express';
import { z } from 'zod';
import * as tareas from '../services/tareas.js';
import { validar } from '../middleware/validar.js';
import { requiereAuth, requiereRol } from '../middleware/auth.js';
import { HttpError } from '../errors.js';
const esquemaCrear = z.object({
titulo: z.string().trim().min(3, 'Mínimo 3 caracteres').max(200),
hecha: z.boolean().optional(),
});
const esquemaActualizar = esquemaCrear.partial().refine((d) => Object.keys(d).length > 0, {
message: 'Envía al menos un campo',
});
const esquemaListado = z.object({
hecha: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
pagina: z.coerce.number().int().min(1).default(1),
porPagina: z.coerce.number().int().min(1).max(100).default(20),
});
const esquemaId = z.object({ id: z.uuid() });
export const tareasRouter = Router();
// Todas las rutas de este router exigen estar identificado
tareasRouter.use(requiereAuth);
// router.param: se ejecuta una vez por petición cuando la ruta tiene :id
tareasRouter.param('id', async (req, res, next, id) => {
if (!esquemaId.safeParse({ id }).success) throw new HttpError(400, 'Id no válido');
const tarea = await tareas.obtener(id);
if (!tarea) throw new HttpError(404, 'La tarea no existe');
req.tarea = tarea;
next();
});
tareasRouter.get('/', validar(esquemaListado, 'query'), async (req, res) => {
res.json(await tareas.listar(req.validado.query));
});
tareasRouter.get('/:id', (req, res) => {
res.json(req.tarea);
});
tareasRouter.post('/', validar(esquemaCrear), async (req, res) => {
const tarea = await tareas.crear(req.validado.body, req.usuario.nombre);
res.status(201).location(`${req.baseUrl}/${tarea.id}`).json(tarea);
});
tareasRouter.patch('/:id', validar(esquemaActualizar), async (req, res) => {
res.json(await tareas.actualizar(req.tarea.id, req.validado.body));
});
// Solo los administradores pueden borrar
tareasRouter.delete('/:id', requiereRol('admin'), async (req, res) => {
await tareas.borrar(req.tarea.id);
res.sendStatus(204);
});Fíjate en lo que no hay: ni un try/catch, ni un if (!req.body.titulo). router.param('id') carga la tarea una sola vez para las tres rutas que tienen :id, y lanza un 404 si no existe. res.location() pone la cabecera Location que indica la URL del recurso creado, como manda REST.
El manejador de errores
// src/middleware/errores.js
import { HttpError } from '../errors.js';
export function noEncontrado(req, res) {
res.status(404).json({ error: `No existe ${req.method} ${req.originalUrl}` });
}
// eslint-disable-next-line no-unused-vars
export function manejarErrores(err, req, res, next) {
if (res.headersSent) return next(err);
// Errores de express.json() (JSON mal formado, cuerpo demasiado grande…)
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'El cuerpo no es JSON válido' });
}
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'El cuerpo es demasiado grande' });
}
if (err instanceof HttpError) {
return res.status(err.status).json({ error: err.message, detalles: err.details });
}
console.error(err);
res.status(500).json({ error: 'Error interno del servidor' });
}Los errores de express.json() traen una propiedad type (entity.parse.failed, entity.too.large) y un status, y así los distingues. Todo lo que no es un error "esperado" se registra entero en el log y se responde con un 500 genérico: nunca envíes el mensaje ni la pila de un error interno al cliente, puede revelar rutas, consultas o versiones.
Montar la aplicación
// src/app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import compression from 'compression';
import { rateLimit } from 'express-rate-limit';
import { config } from './config.js';
import { authRouter } from './routes/auth.js';
import { tareasRouter } from './routes/tareas.js';
import { noEncontrado, manejarErrores } from './middleware/errores.js';
export function crearApp() {
const app = express();
// Detrás de Traefik/Coolify/Nginx: confía en UN proxy para leer X-Forwarded-*
// (IP real del cliente en req.ip, req.protocol === 'https', cookies secure…)
app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use(helmet());
app.use(cors({ origin: config.corsOrigin, credentials: true }));
app.use(compression());
if (config.env !== 'test') app.use(morgan(config.env === 'production' ? 'combined' : 'dev'));
app.use(express.json({ limit: '100kb' }));
// Salud para Docker/Coolify: antes del rate limit y sin autenticación
app.get('/health', (req, res) => res.json({ ok: true, uptime: process.uptime() }));
const limitador = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
limit: 100, // 100 peticiones por IP y ventana
standardHeaders: 'draft-8',
legacyHeaders: false,
});
app.use('/api', limitador);
app.use('/api/auth', rateLimit({ windowMs: 60_000, limit: 5 }), authRouter);
app.use('/api/tareas', tareasRouter);
app.use(noEncontrado);
app.use(manejarErrores);
return app;
}Arranque y apagado ordenado
// src/server.js
import { crearApp } from './app.js';
import { config } from './config.js';
const app = crearApp();
const server = app.listen(config.port, '0.0.0.0', (error) => {
if (error) throw error; // Express 5: los errores de listen (p. ej. EADDRINUSE) llegan aquí
console.log(`API en http://localhost:${config.port} (${config.env})`);
});
// Cierre ordenado: Docker envía SIGTERM al parar o redesplegar el contenedor
function apagar(senal) {
console.log(`${senal} recibido: dejo de aceptar conexiones…`);
server.close((err) => {
// aquí cerrarías también la base de datos, colas, etc.
console.log('Servidor cerrado. Adiós.');
process.exit(err ? 1 : 0);
});
server.closeIdleConnections(); // suelta las conexiones keep-alive ociosas
// Si en 10 s no ha terminado, salimos a la fuerza
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => apagar('SIGTERM'));
process.on('SIGINT', () => apagar('SIGINT'));Cuando Docker, Coolify o Kubernetes paran o redespliegan un contenedor, envían SIGTERM y esperan unos segundos antes de matarlo con SIGKILL. Si no capturas SIGTERM, las peticiones en curso se cortan de golpe. Con server.close() el servidor deja de aceptar conexiones nuevas, termina las que está atendiendo y solo entonces sale. También fíjate en el callback de listen: en Express 5 recibe el error si el puerto no se puede abrir:
// Express 5: los errores al abrir el puerto llegan al callback
app.listen(3000, (error) => {
if (error) throw error; // p. ej. EADDRINUSE: el puerto ya está ocupado
console.log('Escuchando en el puerto 3000');
});Probarlo a mano
# 1. Iniciar sesión y guardar el token
TOKEN=$(curl -s -X POST localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"nombre":"ana","clave":"ana123"}' | jq -r .token)
# 2. Crear una tarea
curl -s -X POST localhost:3000/api/tareas \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"titulo":"Desde curl"}'
# {"id":"6a654f5d-…","titulo":"Desde curl","hecha":false,"autor":"ana","creada":"2026-09-29T17:42:59.188Z"}
# 3. Listar con filtros (validados y convertidos por Zod)
curl -s "localhost:3000/api/tareas?hecha=false&pagina=1&porPagina=10" \
-H "Authorization: Bearer $TOKEN"
# 4. Sin token
curl -s localhost:3000/api/tareas
# {"error":"Falta el token"}La respuesta de creación trae las cabeceras de express-rate-limit en el formato del borrador 8 del IETF (RateLimit: "100-in-15min"; r=98; t=900), y helmet ha añadido su Content-Security-Policy, sin que hayamos escrito una línea para ello.
Tests con supertest
supertest recibe tu app, la arranca en un puerto aleatorio, lanza la petición y la cierra. Combinado con el test runner que ya trae Node (node:test), no necesitas ni Jest ni Mocha:
// test/tareas.test.js
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { crearApp } from '../src/app.js';
import { vaciar } from '../src/services/tareas.js';
const app = crearApp(); // supertest arranca y para el servidor por nosotros
async function token(nombre = 'ana', clave = 'ana123') {
const res = await request(app).post('/api/auth/login').send({ nombre, clave });
return res.body.token;
}
beforeEach(() => vaciar());
test('sin token devuelve 401', async () => {
const res = await request(app).get('/api/tareas');
assert.equal(res.status, 401);
assert.equal(res.body.error, 'Falta el token');
});
test('crea, lee, actualiza y borra una tarea', async () => {
const auth = { Authorization: `Bearer ${await token()}` };
const creada = await request(app).post('/api/tareas').set(auth).send({ titulo: 'Probar Express 5' });
assert.equal(creada.status, 201);
assert.match(creada.headers.location, /^\/api\/tareas\//);
const { id } = creada.body;
const leida = await request(app).get(`/api/tareas/${id}`).set(auth);
assert.equal(leida.body.titulo, 'Probar Express 5');
const cambiada = await request(app).patch(`/api/tareas/${id}`).set(auth).send({ hecha: true });
assert.equal(cambiada.body.hecha, true);
const lista = await request(app).get('/api/tareas?hecha=true').set(auth);
assert.equal(lista.body.total, 1);
await request(app).delete(`/api/tareas/${id}`).set(auth).expect(204);
await request(app).get(`/api/tareas/${id}`).set(auth).expect(404);
});
test('valida el cuerpo con Zod', async () => {
const res = await request(app)
.post('/api/tareas')
.set('Authorization', `Bearer ${await token()}`)
.send({ titulo: 'a' });
assert.equal(res.status, 400);
assert.deepEqual(res.body.detalles, [{ campo: 'titulo', mensaje: 'Mínimo 3 caracteres' }]);
});
test('un usuario normal no puede borrar', async () => {
const auth = { Authorization: `Bearer ${await token('luis', 'luis123')}` };
const { body } = await request(app).post('/api/tareas').set(auth).send({ titulo: 'Mía' });
await request(app).delete(`/api/tareas/${body.id}`).set(auth).expect(403);
});
test('JSON mal formado devuelve 400', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send('{"nombre": ');
assert.equal(res.status, 400);
});$ npm test
✔ sin token devuelve 401
✔ crea, lee, actualiza y borra una tarea
✔ valida el cuerpo con Zod
✔ un usuario normal no puede borrar
✔ JSON mal formado devuelve 400
ℹ tests 5
ℹ pass 5
ℹ fail 0Estos tests cubren lo importante: el flujo feliz completo, la autenticación, la autorización por rol, la validación y un JSON roto. Se ejecutan en menos de un segundo porque no hay red real ni base de datos; con una base de datos de verdad, usa un contenedor de PostgreSQL para tests y vacíalo en beforeEach.
Subida de ficheros con multer
Express no entiende multipart/form-data (el formato de los formularios con ficheros). Para eso está multer, del propio proyecto Express:
import express from 'express';
import multer from 'multer';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
const app = express();
const almacen = multer.diskStorage({
destination: 'uploads/',
// Nunca uses el nombre que manda el cliente tal cual: genera uno propio
filename: (req, file, cb) => cb(null, randomUUID() + path.extname(file.originalname).toLowerCase()),
});
const subir = multer({
storage: almacen,
limits: { fileSize: 2 * 1024 * 1024, files: 5 }, // 2 MB por fichero, máximo 5
fileFilter: (req, file, cb) => {
const ok = ['image/png', 'image/jpeg', 'image/webp'].includes(file.mimetype);
cb(ok ? null : new Error('Solo se admiten imágenes PNG, JPEG o WebP'), ok);
},
});
// Un fichero en el campo "avatar" + campos de texto en req.body
app.post('/avatar', subir.single('avatar'), (req, res) => {
res.status(201).json({
guardado: req.file.filename,
tamano: req.file.size,
usuario: req.body.usuario,
});
});
// Varios ficheros en el campo "fotos"
app.post('/galeria', subir.array('fotos', 5), (req, res) => {
res.status(201).json({ ficheros: req.files.map((f) => f.filename) });
});
app.use((err, req, res, next) => {
const status = err instanceof multer.MulterError ? 413 : 400;
res.status(status).json({ error: err.message, codigo: err.code });
});
app.listen(3004, () => console.log('subidas en :3004'));curl -F usuario=ana -F [email protected] localhost:3004/avatar
# {"guardado":"9530322e-….png","tamano":12,"usuario":"ana"}
curl -F [email protected] localhost:3004/avatar
# {"error":"Solo se admiten imágenes PNG, JPEG o WebP"}
curl -F [email protected] localhost:3004/avatar
# {"error":"File too large","codigo":"LIMIT_FILE_SIZE"}mimetype lo declara el cliente, así que el filtro solo evita errores honestos. Si la seguridad importa, comprueba el contenido real del fichero (sus primeros bytes, con una librería como file-type), guarda las subidas fuera de las carpetas que sirves con express.static y, si son imágenes, vuelve a procesarlas con sharp para eliminar lo que no sea imagen.Plantillas: HTML generado en el servidor
Express no es solo para APIs. Con un motor de plantillas genera páginas HTML completas. EJS es el más sencillo porque es HTML con JavaScript incrustado:
import express from 'express';
const app = express();
app.set('view engine', 'ejs'); // busca las plantillas en ./views
app.set('views', './views');
app.get('/', (req, res) => {
res.render('tareas', {
titulo: 'Mis tareas',
tareas: [
{ titulo: 'Leer sobre Express 5', hecha: true },
{ titulo: 'Migrar mi API <script>', hecha: false }, // <%= %> escapa el HTML
],
});
});
app.listen(3006);<!-- views/tareas.ejs -->
<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title><%= titulo %></title></head>
<body>
<h1><%= titulo %></h1>
<ul>
<% for (const t of tareas) { %>
<li><%= t.titulo %> <% if (t.hecha) { %>✔<% } %></li>
<% } %>
</ul>
</body>
</html><%= %> escapa el HTML (el título con <script> sale como <script>, lo he comprobado), y <%- %> lo inserta sin escapar: úsalo solo con contenido tuyo. En Express 5, res.render() es siempre asíncrono internamente, aunque el motor sea síncrono, lo que evita errores raros de orden.
TypeScript
Express está escrito en JavaScript, pero los tipos de la comunidad (@types/express, versión 5) son muy completos. Lo más útil: los genéricos de Request para tipar parámetros, cuerpo y query, y la fusión de declaraciones para añadir tus propiedades a req:
import express, { type Request, type Response, type NextFunction } from 'express';
interface Usuario {
id: number;
nombre: string;
}
// Añadimos "usuario" al tipo Request de Express (declaration merging)
declare global {
namespace Express {
interface Request {
usuario?: Usuario;
}
}
}
// Tipos genéricos de Request: <Params, ResBody, ReqBody, Query>
type ParamsId = { id: string };
type CrearNota = { texto: string };
type Nota = { id: number; texto: string; autor: string };
const app = express();
app.use(express.json());
const notas: Nota[] = [];
app.use((req: Request, _res: Response, next: NextFunction) => {
req.usuario = { id: 1, nombre: 'ana' };
next();
});
app.get('/notas/:id', (req: Request<ParamsId>, res: Response<Nota | { error: string }>) => {
const nota = notas.find((n) => n.id === Number(req.params.id));
if (!nota) {
res.status(404).json({ error: 'No existe' });
return;
}
res.json(nota);
});
app.post('/notas', (req: Request<{}, Nota, CrearNota>, res: Response<Nota>) => {
const nota: Nota = { id: notas.length + 1, texto: req.body.texto, autor: req.usuario!.nombre };
notas.push(nota);
res.status(201).json(nota);
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).json({ error: err.message });
});
app.listen(3005, () => console.log('TypeScript en :3005'));// tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "ts"
},
"include": ["ts"]
}npm install -D typescript tsx @types/express @types/node
# Desarrollo: ejecuta TypeScript directamente
npx tsx watch ts/app.ts
# Producción: compila a JavaScript y ejecuta el resultado
npx tsc -p .
node dist/app.jsLos genéricos de Request son, en orden, <Params, ResBody, ReqBody, Query>. Un detalle que confunde al principio: en un manejador tipado con Response<…>, return res.json(…) da error de tipos porque la función debería devolver void; por eso el ejemplo responde y luego hace return; en una línea aparte.
req.body tipado como CrearNota puede traer cualquier cosa. Para tener las dos cosas a la vez, define el esquema en Zod y saca el tipo con z.infer<typeof esquema>. Es lo que Fastify y Hono hacen de serie, y una de las razones por las que ganan adeptos.En producción: detrás de un proxy y en Docker
trust proxy
En mi servidor, como en casi todos hoy, la aplicación no recibe las peticiones directamente: pasan por Traefik (el proxy de Coolify) y, antes, por Cloudflare. Para tu aplicación, todas las peticiones vienen de la IP del proxy, y el protocolo es http aunque el usuario use https. El proxy manda los datos reales en cabeceras X-Forwarded-For y X-Forwarded-Proto, pero Express no se fía de ellas a menos que se lo digas:
app.set('trust proxy', 1); // confía en el primer proxy (Traefik)
app.set('trust proxy', 2); // Cloudflare → Traefik → app
app.set('trust proxy', 'loopback'); // solo si el proxy está en 127.0.0.1
app.set('trust proxy', ['10.0.0.0/8', '172.16.0.0/12']); // redes concretas (las de Docker)Sin esto, express-rate-limit limita a todos tus usuarios a la vez (ve una sola IP, la del proxy), req.secure es false y las cookies secure no se envían. Pero no pongas true a ciegas: si confías en todos los saltos, cualquier cliente puede falsificar su IP mandando un X-Forwarded-For inventado. Pon el número exacto de proxies que tienes delante. express-rate-limit incluso avisa en el log si detecta una configuración peligrosa.
Dockerfile
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
# Primero solo las dependencias: esta capa se reutiliza mientras no cambie package-lock.json
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY src ./src
# No ejecutes como root dentro del contenedor
USER node
EXPOSE 3000
# Health check para Docker (Coolify también puede usar /health)
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
# "node" directamente, no "npm start": así SIGTERM llega a nuestro proceso
CMD ["node", "src/server.js"]# .dockerignore
node_modules
npm-debug.log
.env
test
ts
dist
uploads
.gitdocker build -t api-tareas .
docker run -d --name api -p 3000:3000 -e JWT_SECRET=una-clave-larga-de-verdad api-tareas
docker ps --format '{{.Names}} {{.Status}}'
# api Up 20 seconds (healthy)
docker stop api # envía SIGTERM → cierre ordenado
docker logs api
# API en http://localhost:3000 (production)
# SIGTERM recibido: dejo de aceptar conexiones…
# Servidor cerrado. Adiós.Detalles que marcan la diferencia: npm ci --omit=dev instala exactamente lo del package-lock.json sin las dependencias de desarrollo; USER node evita ejecutar como root; el HEALTHCHECK usa wget, que ya viene en Alpine; y el CMD ejecuta node directamente. Si usas npm start, npm recibe SIGTERM y tu proceso a veces no, y el apagado ordenado deja de funcionar. En Coolify, recuerda además escuchar en 0.0.0.0, no en 127.0.0.1 (lo expliqué en Coolify a fondo).
Otras recomendaciones de producción
NODE_ENV=production: Express cachea las plantillas y da mensajes de error menos detallados.app.disable('x-powered-by')(helmet también lo quita): no anuncies qué framework usas.- Logs en JSON con pino (
pino-http) si los vas a mandar a un sistema de logs: es mucho más rápido que morgan. - Un solo proceso de Node usa un núcleo. Para aprovechar varios, arranca varias réplicas del contenedor detrás del proxy, mejor que el módulo
cluster. - Limita el tamaño de los cuerpos (
express.json({ limit })) y el tiempo de las peticiones (server.requestTimeout, 300 s por defecto en Node). - Mantén Express actualizado:
npm outdatedynpm auditen tu CI. La 5.2.0 fue precisamente una versión de seguridad.
Qué cambia en Express 5: la lista completa
Si vienes de Express 4, estos son todos los cambios que te pueden afectar, agrupados por tipo. Los he comprobado uno a uno con la 5.2.1.
1. La sintaxis de las rutas (path-to-regexp 8)
Es el cambio que más código rompe, y lo hace al arrancar, no en ejecución: Express lanza un error en cuanto registras una ruta con la sintaxis vieja. Es buena noticia, porque te enteras enseguida:
app.get('*', …)
// TypeError: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info
app.get('/:id?', …)
// TypeError: Unexpected ? at index 4: /:id?
app.get('/user/:id(\\d+)', …)
// TypeError: Unexpected ( at index 9: /user/:id(\d+)
| Express 4 | Express 5 | Notas |
|---|---|---|
/* o * | /*splat o /{*splat} | El comodín necesita nombre. req.params.splat es un array de segmentos. Con llaves, también coincide con la raíz. |
/:id? | {/:id} | Lo opcional va entre llaves, incluido el separador. |
/:id(\d+) | /:id + validar en el código | No se admiten expresiones regulares dentro de la ruta (pero puedes pasar un objeto RegExp como ruta). |
/:file.:ext? | /:file{.:ext} | Los caracteres ()[]?+! están reservados; escápalos con \ si los necesitas literales. |
req.params[0] | req.params.nombre | Ya no hay parámetros numerados para los comodines. |
// Todas las rutas bajo /ficheros, pero NO /ficheros a secas
app.get('/ficheros/*ruta', …); // /ficheros/a/b → req.params.ruta = ['a', 'b']
// Igual, pero también acepta /ficheros (el comodín es opcional)
app.get('/ficheros{/*ruta}', …); // /ficheros → req.params.ruta = undefined
// "Todo lo demás" (por ejemplo, para una SPA): antes app.get('*')
app.get('/{*cualquiera}', (req, res) => res.sendFile('index.html', { root: 'dist' }));
// Parámetro opcional: antes '/:id?'
app.get('/usuarios{/:id}', …);
// Segmentos opcionales encadenados
app.get('/:id{/:accion}', …); // /7 y /7/editar2. Promesas y errores
- Los manejadores que devuelven una promesa rechazada o lanzan dentro de
asyncllaman anext(err)automáticamente (lo vimos arriba). app.listen()pasa el error al callback en lugar de emitirlo en el servidor.res.status()solo acepta enteros entre 100 y 999; si no, lanza un error.
res.status(1000); // Error: Invalid status code: 1000. Status code must be greater than 99 and less than 1000.
res.status(200.5); // Error: Invalid status code: 200.5. Status code must be an integer.3. Métodos eliminados
| Eliminado | Sustituto |
|---|---|
app.del() | app.delete() |
req.param('id') | req.params.id, req.body.id o req.query.id, explícitamente |
res.send(404) | res.sendStatus(404) |
res.send(cuerpo, 201) / res.json(obj, 201) / res.jsonp(obj, 201) | res.status(201).json(obj) |
res.redirect(url, 301) | res.redirect(301, url) |
res.redirect('back') y res.location('back') | res.redirect(req.get('Referrer') || '/') |
res.sendfile() | res.sendFile() (con F mayúscula, ruta absoluta o root) |
req.acceptsCharset(), acceptsEncoding(), acceptsLanguage() | Las versiones en plural: req.acceptsCharsets(), etc. |
app.param(fn) y router.param(fn) con una función como primer argumento | Solo app.param('nombre', fn) |
express.static.mime | El paquete mime-types |
Opciones hidden y from de res.sendFile() | dotfiles y root |
4. Comportamientos que cambian
req.bodyesundefined(no{}) si no hay un parser montado o si la petición no trae un cuerpo del tipo esperado. Código que hacíareq.body.xsin comprobar puede lanzar errores nuevos.req.queryes un getter de solo lectura, y el parser por defecto pasa deextendedasimple:?a[b]=1ya no se convierte en un objeto anidado.express.urlencoded()tieneextended: falsepor defecto.express.static()ignora los ficheros que empiezan por punto (dotfiles: 'ignore'): tu.envya no se sirve por accidente si está en la carpeta pública. Si necesitas.well-known, sírvelo con otroexpress.staticespecífico.req.hostincluye el puerto (localhost:3014); para el nombre sin puerto,req.hostname.res.clearCookie()ignoramaxAgeyexpiresque le pases.res.render()siempre es asíncrono.res.vary()lanza un error si no le pasas el campo.req.paramses un objeto sin prototipo cuando la ruta es un texto.app.routervuelve a existir (se quitó en Express 4): ahora es una referencia al router base de la aplicación.- Los parsers del cuerpo (
express.json(),urlencoded(),text(),raw()) descomprimen Brotli (Content-Encoding: br) además de gzip y deflate. - Node.js 18 o superior. Se eliminaron un montón de polyfills y dependencias antiguas.
// GET /productos?filtro[precio][max]=10&tag=a&tag=b
// Express 5, por defecto ('simple'):
// { 'filtro[precio][max]': '10', tag: ['a', 'b'] }
app.set('query parser', 'extended');
// { filtro: { precio: { max: '10' } }, tag: ['a', 'b'] }Guía de migración de Express 4 a 5
Este es un fichero típico de Express 4 con casi todo lo que ha cambiado. Funciona con Express 4.22, aunque ya avisa de lo obsoleto en la consola:
// Express 4
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.get('/usuarios/:id?', (req, res) => {
res.json(req.params.id ? { uno: req.params.id } : { todos: true });
});
app.get('/archivos/*', (req, res) => {
res.sendfile('public/' + req.params[0]); // obsoleto ya en Express 4
});
app.get('/user/:id(\\d+)', (req, res) => {
res.json({ id: Number(req.param('id')) }); // req.param(): obsoleto
});
app.del('/usuarios/:id', (req, res) => {
res.send(204);
});
app.post('/usuarios', (req, res) => {
res.json(req.body, 201);
});
app.get('/volver', (req, res) => {
res.redirect('back');
});
// En Express 4, un error en una función async NO llega al manejador de errores:
// hay que capturarlo a mano o la petición se queda colgada.
app.get('/datos', async (req, res, next) => {
try {
const datos = await Promise.reject(new Error('falló la consulta'));
res.json(datos);
} catch (err) {
next(err);
}
});
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
app.listen(3015);express deprecated app.del: Use app.delete instead
express deprecated req.param(name): Use req.params, req.body, or req.query instead
express deprecated res.json(obj, status): Use res.status(status).json(obj) instead
express deprecated res.location("back"): use res.location(req.get("Referrer") || "/") …
express deprecated res.send(status): Use res.sendStatus(status) instead
express deprecated res.sendfile: Use res.sendFile insteadY el mismo fichero en Express 5, probado ruta por ruta con curl:
// Express 5
import express from 'express';
const app = express();
app.use(express.json()); // antes: bodyParser.json()
app.use(express.urlencoded({ extended: true })); // en Express 5 el valor por defecto es false
app.set('query parser', 'extended'); // solo si usas ?filtro[precio][max]=10
// '/:id?' → '{/:id}'
app.get('/usuarios{/:id}', (req, res) => {
res.json(req.params.id ? { uno: req.params.id } : { todos: true });
});
// '/archivos/*' + req.params[0] → comodín con nombre (array de segmentos)
app.get('/archivos/*ruta', (req, res) => {
res.sendFile(req.params.ruta.join('/'), { root: 'public' }); // antes: res.sendfile()
});
// '/user/:id(\\d+)' → ya no hay regex en la ruta: valida dentro
app.get('/user/:id', (req, res, next) => {
if (!/^\d+$/.test(req.params.id)) return next('route'); // pasa a la siguiente ruta
res.json({ id: Number(req.params.id) });
});
app.delete('/usuarios/:id', (req, res) => { // antes: app.del()
res.sendStatus(204); // antes: res.send(204)
});
app.post('/usuarios', (req, res) => {
res.status(201).json(req.body); // antes: res.json(req.body, 201)
});
app.get('/volver', (req, res) => {
res.redirect(req.get('Referrer') || '/'); // antes: res.redirect('back')
});
app.get('/host', (req, res) => res.json({ host: req.host, hostname: req.hostname }));
// Antes: try/catch + next(err) en cada handler async. Ahora no hace falta.
app.get('/datos', async (req, res) => {
const datos = await Promise.reject(new Error('falló la consulta'));
res.json(datos);
});
app.use((err, req, res, next) => res.status(err.status ?? 500).json({ error: err.message }));
app.listen(3014);curl localhost:3014/usuarios # {"todos":true}
curl localhost:3014/usuarios/5 # {"uno":"5"}
curl localhost:3014/archivos/index.html # <h1>Estático</h1>
curl localhost:3014/archivos/.env # 404 {"error":"Not Found"} ← los dotfiles se ignoran
curl localhost:3014/user/12 # {"id":12}
curl localhost:3014/user/abc # 404: next('route') y ninguna otra ruta coincide
curl localhost:3014/host # {"host":"localhost:3014","hostname":"localhost"}
curl localhost:3014/datos # 500 {"error":"falló la consulta"} ← sin try/catchEl proceso, paso a paso
- Actualiza Node a 18 o superior (mejor 22 o 24 LTS).
- Ejecuta en Express 4.22 y lee los avisos
express deprecatedde la consola: cada uno es algo que en la 5 ya no existe. Arréglalos todavía en la 4. - Actualiza:
npm install express@5. Si usas TypeScript, también@types/express@5. - Prueba el codemod oficial:
npx codemod@latest @expressjs/v5-migration-recipeautomatiza cambios mecánicos (métodos renombrados,req.param,res.redirect('back')…). Revisa el diff antes de aceptarlo. - Arranca la app. Las rutas con sintaxis vieja fallan al arrancar con un error de path-to-regexp que dice la posición exacta. Arréglalas con la tabla de arriba.
- Busca los comodines (
*) y losreq.params[0]: ahora son arrays con nombre. - Revisa
req.queryyreq.body: si dependías de objetos anidados en la query, añadeapp.set('query parser', 'extended'); si hacíasreq.body.xsin parser, ahora es un error. - Quita los
try/catchque solo hacíannext(err)y los paquetesexpress-async-errorsoexpress-async-handler: ya no hacen falta. - Actualiza el middleware: la mayoría (helmet, cors, morgan, multer 2) ya es compatible. Los que tocaban
req.queryo usaban métodos eliminados pueden fallar. - Ejecuta tus tests. Si no tienes, este es buen momento para escribir unos cuantos con supertest antes de migrar.
Errores comunes (y cómo los resolví)
| Síntoma | Causa | Solución |
|---|---|---|
req.body es undefined | Falta express.json(), está después de la ruta, o el cliente no envía Content-Type: application/json | Monta el parser antes de las rutas y revisa la cabecera del cliente |
| Cannot set headers after they are sent to the client | Respondes dos veces (falta un return, o next() después de responder) | return res.json(…); en el manejador de errores, comprueba res.headersSent |
| La petición se queda cargando para siempre | Un middleware no responde ni llama a next() | Revisa cada rama del middleware, sobre todo los if |
| TypeError: Missing parameter name at index 1: * | Sintaxis de rutas de Express 4 | /*splat o /{*splat} |
| Todo devuelve 404 | El middleware del 404 está antes que las rutas | El 404 y el manejador de errores, siempre al final |
| El manejador de errores no se ejecuta | Tiene tres parámetros en lugar de cuatro | Firma (err, req, res, next) aunque no uses next |
| Rate limit bloquea a todo el mundo a la vez | Detrás de un proxy sin trust proxy: todos tienen la IP del proxy | app.set('trust proxy', 1) (el número de proxies real) |
| Error CORS en el navegador, pero con curl funciona | CORS solo lo aplica el navegador; falta el origen en la lista | Configura cors({ origin: [...] }) con el origen exacto, protocolo y puerto incluidos |
| Bad Gateway en Coolify/Docker | La app escucha en 127.0.0.1 | Escucha en 0.0.0.0 |
Error al reasignar req.query | En Express 5 es de solo lectura | Guarda los datos validados en otra propiedad (req.validado) |
Cuándo elegir Express (y cuándo no)
Elige Express si quieres lo más conocido y documentado, si tu equipo ya lo domina, si dependes de middleware que solo existe para Express (passport y sus cientos de estrategias, por ejemplo), si estás aprendiendo backend (su modelo mental es el más simple que existe) o si vas a usar NestJS, que lo usa por debajo por defecto.
Mira otras opciones si el rendimiento bruto es crítico (Fastify y Hono son claramente más rápidos en las pruebas sintéticas), si quieres validación y tipos integrados desde el esquema sin pegar librerías, si vas a desplegar en el edge (Cloudflare Workers, Deno, Bun: ahí brilla Hono) o si tu equipo es grande y necesita una arquitectura impuesta (NestJS).
Dicho esto, en la mayoría de aplicaciones el cuello de botella es la base de datos, no el framework. Express 5 es una elección perfectamente sensata en 2026: estable, sin sorpresas y, por fin, con async/await de verdad.
Sigue leyendo: la serie de frameworks de Node
Este artículo forma parte de una serie con el mismo formato (historia, cómo funciona, descarga, ejemplos probados y producción):
- Hono a fondo: el framework ultraligero basado en estándares web que funciona en Node, Bun, Deno y Cloudflare Workers.
- Fastify a fondo: rendimiento, esquemas JSON y un sistema de plugins encapsulado.
- NestJS 12 a fondo: arquitectura por módulos, inyección de dependencias y decoradores, con las novedades de la versión 12.
- Hono, Fastify, Express y NestJS: comparativa: ventajas de cada uno, pruebas de rendimiento reales y cuál elegir según el proyecto.